Sparse ACLs: Identity Center permission-set scope bindings + Organizations hierarchy#129
Sparse ACLs: Identity Center permission-set scope bindings + Organizations hierarchy#129c1-squire-dev[bot] wants to merge 5 commits into
Conversation
Reshape Identity Center permission-set assignments into the c1 Sparse-ACL
contract: a permission_set role type, a permission_set_assignment binding type
carrying TRAIT_SCOPE_BINDING with ScopeBindingTrait{role,scope}, an "assigned"
entitlement, account ChildResourceType wiring, and trait-reading Grant/Revoke
that reuse the existing Create/DeleteAccountAssignment core (HYBRID coexistence:
the legacy account-entitlement path is retained).
Co-authored-by: c1-squire-dev[bot] <c1-squire-dev[bot]@users.noreply.github.com>
Add unit tests for the §3.6 gate invariants: object-id shape with a real permission-set ARN, byte-match to c1's JIT fabrication, role-id drift guard between the role resource and the trait, trait byte-match, the "assigned" slug, and the ResourceIDToString/ParseV2ExternalID round-trip. Regenerate baton_capabilities.json to declare permission_set (TRAIT_ROLE) and permission_set_assignment (TRAIT_SCOPE_BINDING + CAPABILITY_PROVISION, optInRequired). Co-authored-by: c1-squire-dev[bot] <c1-squire-dev[bot]@users.noreply.github.com>
Connector PR Review: Sparse ACLs: Identity Center permission-set scope bindings + Organizations hierarchyBlocking Issues: 0 | Suggestions: 0 | Threads Resolved: 0 Review Summary Security Issues Correctness Issues Suggestions |
…cy, behavioral tests, real-SDK reconcile keys R1: remove V1Identifier from the new permission_set role resource (brand-new sparse-ACL type has no v1 predecessor; matches confluence/azure exemplars and the sibling binding resource). T3: provisionAssignment now pre-checks ListAccountAssignments and emits GrantAlreadyExists (mirroring the existing GrantAlreadyRevoked-on-404 path) instead of relying on implicit CreateAccountAssignment idempotency. T1: add behavioral unit tests via a new ssoAdminAPI/orgsAPI client seam and an in-memory fake — binding List emits the scope-binding resource+trait, Grant calls CreateAccountAssignment with the right scope/role/principal, Revoke deletes the right assignment, and both idempotency annotations fire. T2: the reconcile-key test now pins against the REAL baton-sdk constructors (resource.NewResourceID + entitlement.NewEntitlementID); removed the circular local ResourceIDToString/ParseV2ExternalID replica (those are c1 platform internals, not in the connector module graph — end-to-end "::" round-trip is covered by the .c1z grep step). Pagination: confirmed binding List paginates fully via the SDK paginator in getOrFetchPermissionSetIDs (no single-page truncation); no change needed. Co-authored-by: c1-squire-dev[bot] <c1-squire-dev[bot]@users.noreply.github.com>
Add the AWS Organizations org tree as Sparse ACLs scope context so c1's by-inheritance review walks Account → OU → Root with the role pinned. - New `organization` (root) and `organizational_unit` resource types: scope tiers only (SkipEntitlementsAndGrants + OptInRequired), no bindings — AWS Identity Center assigns permission sets to ACCOUNTS only, so there is no native OU/Root-level assignment. The tiers are hierarchy/review context. - New org/OU builders walk ListRoots → ListOrganizationalUnitsForParent (recursive, via ChildResourceType) with pagination.Bag, parenting each OU to its crawl seed. - account.List re-parents each account onto its Root/OU via ListParents, wiring the hierarchy edge the by-inheritance ancestor walk needs. - Fail-soft: missing organizations:* read permission degrades to flat accounts with a WARN rather than aborting the sync (errors otherwise propagate — no false-revocation). New org-read perms declared on the account / org / OU capability sets; baton_capabilities.json regenerated. - Behavioral tests via the existing client-mock seam: root/OU emission, nested-OU recursion, parent-type gating, account re-parenting, and the access-denied fail-soft paths. Scope-bindings stay account-level and the TRAIT_SCOPE_BINDING contract is unchanged; this commit only adds hierarchy context above the binding. Co-authored-by: c1-squire-dev[bot] <c1-squire-dev[bot]@users.noreply.github.com>
permissionSetAssignmentResourceType.List buffered every provisioned permission set for an account into a single response with no page token, doing one DescribePermissionSet per set synchronously — unlike account.Entitlements, which batches via entitlementsBatchSize. For accounts with many provisioned permission sets this is a large synchronous fan-out per call. Reuse the existing account-entitlement batching mechanism verbatim (entitlementsPageState + entitlementsBatchSize + encode/decodePageToken) so binding List paginates identically to account.Entitlements: at most one batch of bindings per call, with a page token resuming at the next index. Keeps the rate-limit blast radius small enough to resume from a checkpoint (D-SACL-007 fidelity — no new paging mechanism). Co-authored-by: c1-squire-dev[bot] <c1-squire-dev[bot]@users.noreply.github.com>
Summary
Sparse ACLs (Cloud Infrastructure Access) for AWS Identity Center, in two phases:
TRAIT_SCOPE_BINDINGresource type carrying per-bindingScopeBindingTrait{role_id, scope_resource_id}, an"assigned"entitlement, a parent-pointer hierarchy edge, and Grant/Revoke that read the trait — so the AWS app ingests as SPARSE/HYBRID and lights up theRoleScopeBindingRelationship/ JIT / by-inheritance UAR / JML pipelines.Neither phase adds new mutation paths: all native integration (ListAccountAssignments, Create/DeleteAccountAssignment with status polling, conflict/suspended-account handling, group→member expansion) already existed and is reused unchanged. Phase 2 adds read-only Organizations tree calls.
What Phase 1 implements
permission_setrole type (TRAIT_ROLE) — one resource per permission-set ARN; resource id is the bare ARN via the single shared helperpermissionSetRoleID.pkg/connector/permission_set.go.permission_set_assignmentbinding type (TRAIT_SCOPE_BINDING,OptInRequired) — one resource per(account, permission set)provisioned, carryingScopeBindingTrait{role=permission_set, scope=account}, with the binding's parent set to the account (hierarchy edge for the by-inheritance ancestor walk).pkg/connector/permission_set_assignment.go."assigned"entitlement on the binding (slug exactly"assigned"), grantable to SSO users and groups.principal → binding → "assigned", reusing the existing per-assignment grant construction (direct grant for users;GrantExpandable{sso_group:<arn>:member}for groups — sparse and grant-expansion coexist).ScopeBindingTrait(never parse the object id) to recover(account, permission set), then call a sharedprovisionAssignment/deprovisionAssignmentcore extracted from the existing account path — so AWS semantics (status verification,TargetType=AWS_ACCOUNT, status polling, idempotency annotations) are byte-identical.accountadvertisesChildResourceType{permission_set_assignment}so the SDK crawls bindings under each account.ssoEnabled && orgsEnabledblock and in the default-capabilities builder; regeneratedbaton_capabilities.json(binding declaresTRAIT_SCOPE_BINDING+CAPABILITY_PROVISION+optInRequired).Binding object-id / round-trip decision (pre-implementation gate)
The binding object id is
"<permissionSetArn>-<accountID>", built by the single helperpermissionSetAssignmentObjectID, byte-identical to the platform's JIT-fabricated id (<role>-<scope>). The id is treated as an opaque external identity — it is never split back into(role, scope); role/scope are always recovered from theScopeBindingTrait. A permission-set ARN embeds-,:and:::, but this is harmless: the type↔resource separator is::and the resource half (the ARN +-<accountID>) is preserved verbatim by a first-::split. Verified end-to-end and covered by unit tests.HYBRID coexistence
The legacy
account-entitlement Grant/Revoke path is retained (shared provision core), so the app is HYBRID during the cutover window and existing grants keep working. Removing the old account-entitlement path is deferred to a follow-up once HYBRID ingest is validated and in-flight grants are drained.What Phase 2 implements (Organizations hierarchy)
Adds the org tree above the account-leaf bindings as navigation / by-inheritance review context — it does not add OU/Root-level grant targets. AWS Identity Center assigns permission sets to accounts only; there is no native OU- or Root-level assignment, so scope-bindings stay account-level (unchanged) and the new tiers exist purely so c1's by-inheritance UAR resolver can walk
Account → OU → Rootwith the role pinned and the browse tree renders connected.organization(root) resource type andorganizational_unitresource type — scope tiers only (SkipEntitlementsAndGrants+OptInRequired), no traits, no bindings.pkg/connector/organization.go.ListRoots→ListOrganizationalUnitsForParent(recursive viaChildResourceType, paginated withpagination.Bag), parenting each OU to its crawl seed (root or parent OU).account.Listre-parents each active account onto its Root/OU viaListParents, wiring theparent_app_resource_*edge the ancestor walk needs. FlatListAccountsstreaming pagination is preserved — re-parenting is a per-account parent lookup, not a switch to per-OU listing.organizations:*read permission (e.g. existing IAM policies without the new perms) degrades gracefully to flat accounts with a single WARN rather than aborting the sync — bindings are account-leaf regardless, so the tree is genuinely optional context. Any other error propagates (no error-swallow / no false-revocation).organizations:ListParentsonaccount;ListRoots/ListOrganizationalUnitsForParenton the org/OU types); registered the two builders in thessoEnabled && orgsEnabledblock and the default-capabilities builder; regeneratedbaton_capabilities.json.Code-review fixes applied (Phase 1)
This PR has been revised to resolve the principal-engineer review:
V1Identifierthat had been planted on the newpermission_setrole resource. A brand-new sparse-ACL resource type has no v1 predecessor entity, so there is no legacy id to preserve; this matches both proven exemplars (baton-confluencespace_role, baton-azure-infrastructurerole_assignment) and the sibling binding resource, which already omitted it.provisionAssignmentpre-checksListAccountAssignmentsand emitsGrantAlreadyExists(skipping the create) when the assignment is already present, mirroring the existingGrantAlreadyRevoked-on-404 path indeprovisionAssignment, instead of relying on implicitCreateAccountAssignmentserver-side idempotency.ssoAdminAPI/orgsAPIclient seam (the AWS SDK clients satisfy it structurally; production wiring is unchanged) and an in-memory fake, then added tests that exercise behavior, not just id shape: bindingListemits the expected scope-binding resource + trait and is gated on an account parent;GrantcallsCreateAccountAssignmentwith the right scope/role/principal;Revokedeletes the right assignment; and both idempotency annotations (GrantAlreadyExists/GrantAlreadyRevoked) fire.ResourceIDToString/ParseV2ExternalIDreplica with assertions against the real baton-sdk constructors (resource.NewResourceID+entitlement.NewEntitlementID). Thetype::resourceexternal-id codec lives in the c1 platform repo (the SDK uses single-colontype:resource), which is intentionally outside a connector's module graph, so the end-to-end"::"byte-match is verified by the.c1zgrep step against a live sync rather than an unimportable function — see the limitations below.Listcollects all permission sets provisioned to an account by fully draining the SDKListPermissionSetsProvisionedToAccountpaginator insidegetOrFetchPermissionSetIDs; there is no single-page truncation that could silently drop bindings. The bindingGrantscorrectly passes the AWS assignment token through.Testing done
go build ./...— successgo test ./...— all passing (23 sparse-ACL tests inpkg/connector, +10 new for the Phase 2 hierarchy)golangci-lint run ./...— 0 issues"assigned"slug + entitlement id; real-SDK reconcile-key pinning (NewResourceID/NewEntitlementID); and behavioral List/Grant/Revoke/idempotency tests via the faked client.organizations:*access-denied fail-soft paths (org/OU builders and account re-parenting all degrade without error).baton_capabilities.jsonverified to declare the new types with the correct traits/capabilities (purely additive).Deferred to later phases / known limitations
organization/organizational_unittypes areOptInRequired. Account re-parenting is gated on theorganizations:ListParentspermission (fail-soft), not on opt-in state (the SDK does not expose opted-in resource types to a builder). A deployment that grants the perm but does not opt into the org/OU types could produce account parent pointers to un-synced tiers; c1's ancestor walk is gap-tolerant, but enabling the hierarchy types alongside the perm is recommended.account-entitlement grant and once as the sparse binding"assigned"grant. This is intentional per the cutover strategy and harmless in c1 (distinct resource types), but operators will see each access in both models until the legacyaccount-entitlement path is removed in a follow-up (after HYBRID validation + grant drain)..c1zbyte-match verification (c1-side) outstanding. The connector-side bytes (binding object id, trait role/scope ids,"assigned"entitlement id, account/OU/Root parent edges) are fully unit-tested, but the JIT↔sync reconcile byte-match against live c1 (scope_role_jit.go<role>-<scope>shape) and the by-inheritance walk over a real org tree should be confirmed once via a.c1zround-trip on a real Identity Center org before enabling on a tenant.(role, scope)binding).This ships behind
OptInRequired(opt in via--sync-resource-types), so it is dark until enabled per tenant.