Skip to content

Commit c84fd02

Browse files
authored
feat(accounts): wire AWS Organizations discovery endpoint (closes #208) (#212)
* feat(accounts): wire AWS Organizations discovery endpoint to DiscoverOrgAccounts (closes #208) POST /api/accounts/discover-org used to return a stub "not yet implemented" message. Wire it end-to-end against the already-shipped accounts.DiscoverOrgAccounts function: 1. Parse {account_id} body, validate it's a UUID. 2. Load the named account via config.GetCloudAccount; 404 if missing. 3. Validate provider == "aws" and aws_is_org_root == true; 400 otherwise. 4. Resolve the org-root's stored credentials via credentials.ResolveAWSCredentialProvider and build an aws.Config. 5. Call accounts.DiscoverOrgAccounts (injectable seam: Handler.discoverOrgFn for tests; nil-default falls back to the real Organizations API call). 6. Dedupe each member account by (provider="aws", external_id) against the existing aws cloud_accounts roster. 7. Persist new members with enabled=false, aws_auth_mode=bastion, aws_bastion_id pointing at the org root — so the operator must review/approve and fill in the target role ARN before any discovered account participates in scheduled collection. 8. Return {discovered, created, skipped} counts. Spec acceptance F-1, F-2, F-3 from specs/multi-account-execution/acceptance.md. Test additions in handler_accounts_test.go cover the primary acceptance dimensions: - 400 on invalid JSON body - 400 on invalid account_id (not a UUID) - 400 on non-AWS account - 400 on aws_is_org_root=false - 404 on account not found - happy path: 3 discovered, 1 already known (skipped), 2 created; asserts each persisted row has enabled=false + aws_auth_mode=bastion + aws_bastion_id=org-root.ID The router-level smoke test in router_handlers_test.go is updated to assert the routing dispatcher reaches the handler (now expects a ClientError 400 on the empty body it sends, instead of the prior NotNil result from the stub). MockConfigStore gains ListCloudAccountsFn + CreateCloudAccountFn hooks so the org-discovery happy-path test can inject an existing roster and capture the persisted rows. * fix(accounts): tighten discoverOrgAccounts per CR pass 1 — admin-only, drop misleading bastion default, drop blanket-400 (#212) Three CR-pass-1 findings on PR #212 addressed in one commit: 1. Permission scope: switch from requirePermission("create", "accounts") to requireAdmin. Org discovery can create N cloud_accounts rows in one call and bring unfamiliar accounts into the roster — admin scope is the right gate even though the rows boot disabled. requireAdmin honours both the admin API-key and bearer-token paths used elsewhere. 2. Drop the default AWSAuthMode="bastion" on persisted rows. awsAmbientCredResult treats role_arn or bastion modes with an empty AWSRoleARN as "ambient host credentials" — semantically correct for the CUDly self-account, but WRONG for a freshly-discovered member account, which would have falsely passed the test-credentials endpoint. Persist with AWSAuthMode="" instead; the operator's review step must set BOTH the mode AND a non-empty AWSRoleARN before flipping enabled=true. AWSBastionID is still pre-filled with the org root's ID so the operator's edit is just "add role ARN, set mode=bastion". The empty mode also fails ResolveAWSCredentialProvider's switch with a clear "unsupported aws_auth_mode" error if the row is enabled prematurely. 3. buildOrgRootAWSConfig no longer wraps every ResolveAWSCredentialProvider error as ClientError(400). The resolver mixes definite client-side validation failures (missing aws_role_arn) with transient server-side ones (credential store unavailable, network errors during access-key load); without a sentinel/typed error in the credentials package today, blanket-400 was misleading. Wrap the error as a regular Go error instead — the handler-default surface is 5xx, retries are possible, and the error message makes it into the structured log either way. A proper 400/500 split lives in a future credentials-package error-type cleanup; flagged in the doc comment. Test update: TestDiscoverOrgAccounts_HappyPathDedupesAndPersists now asserts AWSAuthMode is empty on persisted rows (was checking "bastion"). The doc above the assertions explains the WHY so a future maintainer doesn't "fix" it back. go test ./... clean. No behaviour change other than the three items above. * fix(accounts): stamp discovered org accounts metadata * fix(accounts): skip duplicate org discoveries on insert * fix(accounts): guard nil org discovery results
1 parent 5c1d38f commit c84fd02

5 files changed

Lines changed: 501 additions & 11 deletions

File tree

internal/api/handler.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"sync"
1010
"time"
1111

12+
"github.com/LeanerCloud/CUDly/internal/accounts"
1213
"github.com/LeanerCloud/CUDly/internal/config"
1314
"github.com/LeanerCloud/CUDly/internal/credentials"
1415
"github.com/LeanerCloud/CUDly/internal/email"
@@ -78,6 +79,12 @@ type Handler struct {
7879
// the integration suite runs hermetically.
7980
reshapeAccountResolver func(context.Context) (string, error)
8081

82+
// Optional org-discovery factory used by tests to avoid live AWS
83+
// Organizations API calls. When nil (production default), the handler
84+
// falls back to accounts.DiscoverOrgAccounts which dials Organizations
85+
// via the credentials resolved for the org-root account.
86+
discoverOrgFn func(context.Context, aws.Config) (*accounts.OrgDiscoveryResult, error)
87+
8188
// commitmentOpts discovers which AWS (term, payment) combinations
8289
// each service actually sells and validates saves against that data.
8390
// Nil is valid: the endpoint returns unavailable and save-side

internal/api/handler_accounts.go

Lines changed: 184 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,14 @@ import (
1010
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
1111
"golang.org/x/oauth2"
1212

13+
"github.com/LeanerCloud/CUDly/internal/accounts"
1314
"github.com/LeanerCloud/CUDly/internal/auth"
1415
"github.com/LeanerCloud/CUDly/internal/config"
1516
"github.com/LeanerCloud/CUDly/internal/credentials"
1617
"github.com/LeanerCloud/CUDly/internal/oidc"
1718
"github.com/aws/aws-lambda-go/events"
19+
"github.com/aws/aws-sdk-go-v2/aws"
20+
"github.com/aws/aws-sdk-go-v2/service/sts"
1821
"github.com/google/uuid"
1922
)
2023

@@ -1086,13 +1089,191 @@ func (h *Handler) listPlanAccounts(ctx context.Context, req *events.LambdaFuncti
10861089
return accounts, nil
10871090
}
10881091

1089-
// discoverOrgAccounts handles POST /api/accounts/discover-org.
1092+
// DiscoverOrgRequest is the request body for POST /api/accounts/discover-org.
1093+
// AccountID is the UUID of the org-root cloud account whose stored credentials
1094+
// will be used to call AWS Organizations.
1095+
type DiscoverOrgRequest struct {
1096+
AccountID string `json:"account_id"`
1097+
}
1098+
1099+
// DiscoverOrgResult is the response shape for POST /api/accounts/discover-org.
1100+
// Discovered is the total number of member accounts the AWS Organizations API
1101+
// returned; Created is the number of new cloud_accounts rows persisted; Skipped
1102+
// is the number that already existed (matched by provider+external_id).
1103+
type DiscoverOrgResult struct {
1104+
Discovered int `json:"discovered"`
1105+
Created int `json:"created"`
1106+
Skipped int `json:"skipped"`
1107+
}
1108+
1109+
// discoverOrgAccounts handles POST /api/accounts/discover-org. The endpoint
1110+
// (1) validates the named cloud account is an AWS org root, (2) resolves its
1111+
// stored credentials, (3) calls AWS Organizations ListAccounts via the
1112+
// credentials, (4) deduplicates against existing aws cloud_accounts rows by
1113+
// external_id, and (5) persists the new ones with enabled=false, an empty
1114+
// aws_auth_mode, and aws_bastion_id pointing at the org root, so an operator
1115+
// must review/approve each discovered account and explicitly choose the
1116+
// bastion auth mode plus role ARN before it'll be picked up by the scheduler.
1117+
// See issue #208 for the spec; see specs/multi-account-execution/
1118+
// acceptance.md F-1..F-3 for the acceptance criteria.
10901119
func (h *Handler) discoverOrgAccounts(ctx context.Context, req *events.LambdaFunctionURLRequest) (any, error) {
1091-
if _, err := h.requirePermission(ctx, req, "create", "accounts"); err != nil {
1120+
// Admin-only: org discovery can create N cloud_accounts rows in one call
1121+
// and may bring unfamiliar accounts into the roster. Even though those
1122+
// rows boot disabled, the elevated privilege makes admin-scope the right
1123+
// gate (CR pass 1 on PR #212).
1124+
if _, err := h.requireAdmin(ctx, req); err != nil {
1125+
return nil, err
1126+
}
1127+
1128+
root, err := h.parseDiscoverOrgRoot(ctx, req.Body)
1129+
if err != nil {
1130+
return nil, err
1131+
}
1132+
1133+
cfg, err := h.buildOrgRootAWSConfig(ctx, root)
1134+
if err != nil {
1135+
return nil, err
1136+
}
1137+
1138+
disco, err := h.runOrgDiscovery(ctx, cfg)
1139+
if err != nil {
10921140
return nil, err
10931141
}
1142+
if disco == nil {
1143+
return DiscoverOrgResult{}, nil
1144+
}
10941145

1095-
return map[string]string{"message": "org discovery not yet implemented"}, nil
1146+
return h.persistDiscoveredMembers(ctx, root, disco.Accounts)
1147+
}
1148+
1149+
// parseDiscoverOrgRoot decodes the request body, loads the named account, and
1150+
// validates it's a usable AWS org root. Returns ClientErrors for the four
1151+
// failure modes the spec enumerates (bad JSON / bad UUID / not-aws / not-root)
1152+
// + 404 for missing-account. Pulled out of discoverOrgAccounts to keep that
1153+
// function under the gocyclo budget.
1154+
func (h *Handler) parseDiscoverOrgRoot(ctx context.Context, rawBody string) (*config.CloudAccount, error) {
1155+
var body DiscoverOrgRequest
1156+
if err := json.Unmarshal([]byte(rawBody), &body); err != nil {
1157+
return nil, NewClientError(400, "invalid JSON body")
1158+
}
1159+
if err := validateUUID(body.AccountID); err != nil {
1160+
return nil, err
1161+
}
1162+
1163+
root, err := h.config.GetCloudAccount(ctx, body.AccountID)
1164+
if err != nil {
1165+
return nil, fmt.Errorf("accounts: get cloud account: %w", err)
1166+
}
1167+
if root == nil {
1168+
return nil, NewClientError(404, "cloud account not found")
1169+
}
1170+
if root.Provider != "aws" {
1171+
return nil, NewClientError(400, "discover-org requires an aws account")
1172+
}
1173+
if !root.AWSIsOrgRoot {
1174+
return nil, NewClientError(400, "account is not configured as an org root (set aws_is_org_root=true)")
1175+
}
1176+
return root, nil
1177+
}
1178+
1179+
// buildOrgRootAWSConfig resolves the org-root account's stored credentials and
1180+
// returns an aws.Config configured to call AWS APIs as that account.
1181+
//
1182+
// Returns the resolver's error wrapped as a regular Go error (which the
1183+
// handler-default mapping surfaces as 500) — NOT a 400 ClientError — because
1184+
// ResolveAWSCredentialProvider mixes definite client-side validation
1185+
// failures (e.g., missing aws_role_arn for role_arn mode) with transient
1186+
// server-side failures (credential store unavailable, network errors during
1187+
// access-key load). Without a sentinel/typed error in the credentials
1188+
// package today, blanket-400 was misleading; 5xx is the safer default and
1189+
// retries are possible. Refining this to a proper 400/500 split is tracked
1190+
// in the credentials-package error-type cleanup (out of scope for this PR;
1191+
// see CR pass 1 on #212).
1192+
func (h *Handler) buildOrgRootAWSConfig(ctx context.Context, root *config.CloudAccount) (aws.Config, error) {
1193+
baseCfg, err := h.getBaseAWSConfig(ctx)
1194+
if err != nil {
1195+
return aws.Config{}, fmt.Errorf("accounts: load base aws config: %w", err)
1196+
}
1197+
stsClient := sts.NewFromConfig(baseCfg)
1198+
credProvider, err := credentials.ResolveAWSCredentialProvider(ctx, root, h.credStore, stsClient)
1199+
if err != nil {
1200+
return aws.Config{}, fmt.Errorf("accounts: resolve credentials for org root %s: %w", root.ID, err)
1201+
}
1202+
cfg := baseCfg.Copy()
1203+
cfg.Credentials = credProvider
1204+
return cfg, nil
1205+
}
1206+
1207+
// runOrgDiscovery dispatches to the configured discovery function — the
1208+
// injectable seam Handler.discoverOrgFn for tests, falling back to the real
1209+
// accounts.DiscoverOrgAccounts in production.
1210+
func (h *Handler) runOrgDiscovery(ctx context.Context, cfg aws.Config) (*accounts.OrgDiscoveryResult, error) {
1211+
discoverFn := h.discoverOrgFn
1212+
if discoverFn == nil {
1213+
discoverFn = accounts.DiscoverOrgAccounts
1214+
}
1215+
disco, err := discoverFn(ctx, cfg)
1216+
if err != nil {
1217+
return nil, fmt.Errorf("accounts: org discovery failed: %w", err)
1218+
}
1219+
return disco, nil
1220+
}
1221+
1222+
// persistDiscoveredMembers dedupes the discovered list against existing aws
1223+
// rows and persists each new one with the spec-mandated defaults
1224+
// (enabled=false, aws_auth_mode="", aws_bastion_id=root.ID). Returns the
1225+
// {discovered, created, skipped} summary.
1226+
func (h *Handler) persistDiscoveredMembers(ctx context.Context, root *config.CloudAccount, members []config.CloudAccount) (DiscoverOrgResult, error) {
1227+
awsProvider := "aws"
1228+
existing, err := h.config.ListCloudAccounts(ctx, config.CloudAccountFilter{Provider: &awsProvider})
1229+
if err != nil {
1230+
return DiscoverOrgResult{}, fmt.Errorf("accounts: list existing aws accounts: %w", err)
1231+
}
1232+
knownExternal := make(map[string]struct{}, len(existing))
1233+
for i := range existing {
1234+
knownExternal[existing[i].ExternalID] = struct{}{}
1235+
}
1236+
1237+
result := DiscoverOrgResult{Discovered: len(members)}
1238+
now := time.Now()
1239+
for i := range members {
1240+
member := members[i]
1241+
if _, found := knownExternal[member.ExternalID]; found {
1242+
result.Skipped++
1243+
continue
1244+
}
1245+
// Defaults the spec mandates: persist disabled (operator review gate)
1246+
// + bastion-id pre-filled with this org root's ID. AWSAuthMode is
1247+
// LEFT EMPTY on purpose — pre-setting it to "bastion" while
1248+
// AWSRoleARN is empty would cause awsAmbientCredResult to falsely
1249+
// classify the row as having valid ambient creds (the role_arn /
1250+
// bastion + empty-AWSRoleARN branch returns OK with the "ambient
1251+
// host" message, which is wrong for a discovered member account).
1252+
// The operator's review step must set both AWSAuthMode="bastion"
1253+
// AND a non-empty AWSRoleARN before flipping enabled=true; the
1254+
// scheduler silently skips disabled rows in the meantime, and the
1255+
// empty AWSAuthMode we persist here also fails
1256+
// ResolveAWSCredentialProvider's switch with a clear
1257+
// "unsupported aws_auth_mode" error if the row is enabled
1258+
// prematurely. (CR pass 1 on PR #212.)
1259+
member.Enabled = false
1260+
member.ID = uuid.New().String()
1261+
member.CreatedAt = now
1262+
member.UpdatedAt = now
1263+
member.AWSAuthMode = ""
1264+
member.AWSBastionID = root.ID
1265+
if err := h.config.CreateCloudAccount(ctx, &member); err != nil {
1266+
if isDuplicateKeyError(err) {
1267+
knownExternal[member.ExternalID] = struct{}{}
1268+
result.Skipped++
1269+
continue
1270+
}
1271+
return DiscoverOrgResult{}, fmt.Errorf("accounts: persist discovered %s: %w", member.ExternalID, err)
1272+
}
1273+
knownExternal[member.ExternalID] = struct{}{}
1274+
result.Created++
1275+
}
1276+
return result, nil
10961277
}
10971278

10981279
// parseServiceOverridePath parses "uuid/service-overrides/provider/service"

0 commit comments

Comments
 (0)