carlos / platform Public

The principal report: the fifth review's findings (the possible reach decided by one text test on both readings, decision 14 names what the host role can mint, the legend's other spellings)

Opus P2-1 and Astra 1: the possible-reach rule skipped the text reading
for a complete pattern on the ground that the two readings differ only
through an object key holding colons, which the direct matcher reports;
that holds for a star and not for a question mark, which stands for a
colon and cannot run into this bucket's keys. "arn:aws:s3?eu-west-1:..."
on an access point, "?rn:aws:s3:..." and "arn:aws:s3?eu-west-1" were read
as nothing. The rule is now one text test on the pattern and, when it is
an incomplete ARN, on its padded form: can it spell "arn:<p>:s3:" or
"arn:<p>:s3::" and then a character that is not a colon (a region or an
account), or anything under "arn:<p>:s3-object-lambda:". Every earlier
fixture answers the same; the three patterns and two more are fixtures.

Opus P2-2: decision 14 said the host role cannot read objects. The host
module lets it create a store user and write that user's policy under
the store boundary (which allows s3:GetObject on carlos-store-*/*) and
mint the user's keys, so it can give itself any store bucket's objects
through a user it makes; the sentence says so, and proposal A names that
as the binding it asks for.

Opus P3s: the note says the text reading lists a star before a key-able
literal as direct; the note's second reason names compute control and
IAM writes; the legend covers a bare account id, a deleted principal's
unique id and a web identity provider's domain; proposal A says a
resource control policy has the management account's limits too; a page
that is neither an answer nor an error is an error, with a test; the
mutation log records each edit and the failing tests (thirteen on this
code, all die, inventory-mutants-round5.txt), and the note claims of the
earlier logs only what they hold. Astra's claims: the sandbox sentence
names what the harness needs; two test comments corrected.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 36e5ff2d340a9c68025b44020e8e323d2236fe42 parent 403384c
3 files changed, +91 −41
  • docs/superpowers/plans/2026-10-02-aws-principal-inventory.md +8 −8
  • internal/objstore/principals.go +49 −28
  • internal/objstore/principals_test.go +34 −5

CI passed — run details

diff --git a/docs/superpowers/plans/2026-10-02-aws-principal-inventory.md b/docs/superpowers/plans/2026-10-02-aws-principal-inventory.md
index 756bfb7bd..c050927d8 100644
--- a/docs/superpowers/plans/2026-10-02-aws-principal-inventory.md
+++ b/docs/superpowers/plans/2026-10-02-aws-principal-inventory.md
@@ -1,6 +1,6 @@
# The AWS principal report, and what a completeness claim needs
-Date: 2026-10-02. Revised the same day after its first review (Astra: reject; three P1s, two P2s, one P3), and on 2026-10-03 after its second (Astra: reject; six P2s) its third (Opus: accept with fixes, two P2s required; Astra: reject, four P2s, two P3s) and its fourth (Opus: accept with fixes, two P2s required; Astra: reject, three P2s). Status: a report for a person, built on `feat/carloku-principal-inventory-20261002`. **It is not wired, and it is no longer offered as the proof a bucket's ledger needs.** Nothing was run against AWS.
+Date: 2026-10-02. Revised the same day after its first review (Astra: reject; three P1s, two P2s, one P3), and on 2026-10-03 after its second (Astra: reject; six P2s) its third (Opus: accept with fixes, two P2s required; Astra: reject, four P2s, two P3s) its fourth (Opus: accept with fixes, two P2s required; Astra: reject, three P2s) and its fifth (both accept with fixes: one P2 each on the possible-reach rule's text reading, and the host role's reach in decision 14). Status: a report for a person, built on `feat/carloku-principal-inventory-20261002`. **It is not wired, and it is no longer offered as the proof a bucket's ledger needs.** Nothing was run against AWS.
## Why this exists
@@ -9,31 +9,31 @@ Object-store transfer is billed to a tenant only if the bucket's ownership ledge
The first build of this branch tried to make the claim by reading the account: list every principal, evaluate every policy, and call an empty answer "nobody else". The review showed that an empty answer from one listing is not that claim, for three reasons that no amount of care in the reading removes:
1. **A listing is not an instant.** IAM is eventually consistent: a policy attached before the listing began can be missing from it. The listing is paged: a principal on page one can be given access before the last page is read. Nothing in the answer says it is complete.
-2. **A principal's policy is not everyone who can act as it.** A role can be assumed by whoever its trust policy names, another account included, and by whoever may pass it to a service. Setting a role aside as "the platform's" sets aside everyone who can become it.
+2. **A principal's policy is not everyone who can act as it.** A role can be assumed by whoever its trust policy names, another account included; by whoever may pass it to a service or run code where it already runs; and whoever can change IAM can grant themselves the bucket. Setting a role aside as "the platform's" sets aside everyone who can become it.
3. **An absent bucket is not a bucket this grant creates.** Another actor in the account can create the name, with a policy of its own, between any probe and the walk's own create; AWS answers the walk's create as a success.
So the branch now does less, and says so.
## What is built
-`internal/objstore/principals.go`: `PrincipalReport.BucketPrincipals(name)`. One listing of the account (`iam:GetAccountAuthorizationDetails`, every page), every identity policy decoded and evaluated against one bucket name, and a report: which users, roles and groups reach the name, directly (the bucket's ARN or an object under it) or possibly (any S3 resource that is not a general-purpose bucket's and whose relation to the bucket the ARN does not show: an access point, a multi-region access point, an Access Grants instance or grant, a Batch Operations job, a Storage Lens configuration, an Object Lambda access point, a type it does not know; marked so, never dropped; nothing S3 names is excluded, since a Storage Lens configuration exports into a bucket of its owner's choosing), through which policies, a group's possible reach carried to its members as possible, and for each role, the principal values its trust policy's Allow statements name, with a legend for what each spelling means (not who can in fact assume it, which the report does not determine). Principals the deployment names as its own are set apart as `named_by_deployment`: the deployment's word, unverified, exempting nothing. A resource pattern is read two ways, as text (a star free to run across colons) and as the six fields of an ARN with the ones an incomplete pattern leaves out as stars, and a reach under either is reported; which reading IAM gives an incomplete pattern is unverified. The legend lives in the Go type's comment; the JSON carries none, and whatever prints the report for a person must print it.
+`internal/objstore/principals.go`: `PrincipalReport.BucketPrincipals(name)`. One listing of the account (`iam:GetAccountAuthorizationDetails`, every page), every identity policy decoded and evaluated against one bucket name, and a report: which users, roles and groups reach the name, directly (the bucket's ARN or an object under it) or possibly (any S3 resource that is not a general-purpose bucket's and whose relation to the bucket the ARN does not show: an access point, a multi-region access point, an Access Grants instance or grant, a Batch Operations job, a Storage Lens configuration, an Object Lambda access point, a type it does not know; marked so, never dropped; nothing S3 names is excluded, since a Storage Lens configuration exports into a bucket of its owner's choosing), through which policies, a group's possible reach carried to its members as possible, and for each role, the principal values its trust policy's Allow statements name, with a legend for what each spelling means (not who can in fact assume it, which the report does not determine). Principals the deployment names as its own are set apart as `named_by_deployment`: the deployment's word, unverified, exempting nothing. A resource pattern is read two ways, as text (a star or a question mark free to run across colons) and as the six fields of an ARN with the ones an incomplete pattern leaves out as stars, and a reach under either is reported; the possible-reach rule is one text test on both forms (can the pattern spell `arn:<partition>:s3:` or `arn:<partition>:s3::` and then a character other than a colon, or anything under `arn:<partition>:s3-object-lambda:`); which reading IAM gives an incomplete pattern is unverified. The legend lives in the Go type's comment; the JSON carries none, and whatever prints the report for a person must print it.
- It is **not** a `GranteeProber`, and a test says so. The grant walk on AWS claims nothing, as before. It is a report to support a person's investigation; it cannot verify that any rule is in force in the account, and identical reports can come from an account with and without one.
- Within the documents it is given it leans toward listing: a Deny, a condition or a boundary is not evaluated; `NotAction`, `NotResource` and policy variables are taken to reach.
- What it cannot read is an error, never a shorter list: an undecodable document; a JSON member given twice anywhere in a document; a statement, element or version outside the grammar it knows, in an identity policy or a trust policy, Deny statements included (a null or empty `Statement`, an empty or null value, a null `Id` or `Sid`, a condition that is not a map of at least one operator each mapping at least one key to a scalar or a non-empty list of scalars, a `Principal` in an identity policy, an unknown element, an unknown principal kind in a trust policy); the same principal, managed policy, inline policy name or policy version id returned twice; a managed policy whose own default-version id and the version marked default disagree; an attached policy the listing did not return; a truncated or endless listing.
- It does not read: the root user; a bucket's own policy, ACL or its access points' own policies; what Batch Operations and replication jobs, or an Access Grants location role, may do under their roles, beyond reporting the principal that may start them as a possible reach; who inside the account may assume a role (`sts:AssumeRole` in their own policies), pass it to a service (`iam:PassRole`) or run code where it already runs (a host under SSM or Instance Connect, a function's code, a container: everyone who controls compute running as a listed role reaches what it reaches); who may change IAM (`iam:PutUserPolicy`, `iam:AttachRolePolicy`, `iam:CreatePolicyVersion` and the like) and so grant themselves the bucket in one call; credentials vended by other services (Lake Formation, Identity Center grantees of Access Grants that are not IAM principals); anything in another account; organization policies and permissions boundaries (which only narrow).
-Use: a person investigating who reaches a store name today, in support of the decisions below. It is not evidence for an attestation: the attestation plan's claim that an IAM read proves completeness is superseded (a banner at its head says so). There is no harness to run it yet: an operator would need a small Go program in this module that loads their own profile, builds an IAM client, calls `NewPrincipalReport` and `BucketPrincipals` with a deadline, and holds `iam:GetAccountAuthorizationDetails`; from a sandboxed session that also needs the sandbox off. Nothing in the console calls it.
+Use: a person investigating who reaches a store name today, in support of the decisions below. It is not evidence for an attestation: the attestation plan's claim that an IAM read proves completeness is superseded (a banner at its head says so). There is no harness to run it yet: an operator would need a small Go program in this module that loads their own profile, builds an IAM client, calls `NewPrincipalReport` and `BucketPrincipals` with a deadline, and holds `iam:GetAccountAuthorizationDetails`; from a sandboxed session that also needs network and credential access the sandbox withholds. Nothing in the console calls it.
-Tests: `go test ./internal/objstore -run 'TestPolicyPatterns|TestInventory|TestPlatformPrincipals|TestNothingOnAWS|TestAnAccessPoint'`, against a stub of the listing. No test reads a real account, and the stub is built from SDK structs, not from IAM's wire format. Both matchers read a resource pattern as text and, when it is an incomplete ARN, as padded fields too, and report a reach under either; which reading IAM gives an incomplete pattern, and whether its wildcards run across colons, is on the list to verify against a real account. The text reading over-reports: an incomplete ARN of another service with a star can match an object key, and is reported as a direct reach.
+Tests: `go test ./internal/objstore -run 'TestPolicyPatterns|TestInventory|TestPlatformPrincipals|TestNothingOnAWS|TestAnAccessPoint'`, against a stub of the listing. No test reads a real account, and the stub is built from SDK structs, not from IAM's wire format. Both matchers read a resource pattern as text and, when it is an incomplete ARN, as padded fields too, and report a reach under either; which reading IAM gives an incomplete pattern, and whether its wildcards run across colons, is on the list to verify against a real account. The text reading over-reports, and in one common case relabels: any star before a literal an object key can hold runs into one of this bucket's keys, so a grant on `arn:aws:s3:*:123456789012:accesspoint/*` is listed as direct rather than possible, and an incomplete ARN of another service with a star (`arn:*:function:thumbs`) is listed as direct; a question mark cannot run into a key that way and is listed as possible.
-Mutation checks, in a tree copy: forty after the first review (thirty-nine die; one is equivalent, dropping the check that a default version has a document, because an absent document then fails to decode); twelve after the second (all die); twenty after the third (the earlier twelve again on the changed code, plus an incomplete ARN read literally, any S3 resource with a region or account read as nothing, a possible group reach propagated as direct, a version id given twice, a condition value of any shape, a null sid, Storage Lens a possible reach, a condition that is not a map, an Object Lambda grant nothing, a non-ARN pattern no access path; all die); ten after the fourth (the text reading dropped from the direct matcher, the padded reading dropped, the text reading dropped from the possible-reach rule and applied to one service only, Storage Lens excluded again, an empty condition, an operator with no key and an empty value list accepted, a group with a direct and a possible reach propagated as possible; all die). Output: `inventory-mutants-round{1,2,3,4}.txt` in the run directory's logs.
+Mutation checks, in a tree copy, each a one-line edit of `principals.go` run against the inventory tests. The logs of the first four rounds (`inventory-mutants-round{1,2,3,4}.txt` in the run directory's logs) record for each mutation its description and whether the tests failed, not which test; their counts are as logged (forty after the first review, one equivalent; twelve after the second; the third's log holds the K, L and M entries with replacements for the ones that first failed to compile; ten after the fourth), and nothing more is claimed of them. The fifth round's log (`inventory-mutants-round5.txt`) records for each mutation the exact edit and the failing tests' names and first assertion, which is the evidence a kill needs; it reruns the fourth round's ten on the changed code and adds the possible-reach rule's text test (the S3 test dropped; the account form dropped; the non-colon requirement dropped; the padded reading dropped; the Object Lambda test dropped). Any that survive are named there.
## What a completeness claim needs instead (not built; needs the owner)
Looking cannot establish it. A rule the account enforces can. Two parts, each a design question, each stated here with what its review found missing in it:
-**A. Nobody else can reach a store bucket.** Proposed direction: an organization policy (or, failing that, a permissions boundary required on every principal the platform creates plus an account rule) that denies S3 actions on the store bucket prefix to every principal except each bucket's own store user and a short list named by ARN. What that needs, beyond the sentence: a binding between each store identity and its one bucket, enforced (a store user under the path with a policy for another store's bucket must be impossible, not merely absent); coverage of the paths an identity policy does not show (resource policies and grants to other accounts, service-linked roles, access points); the enforcement configuration itself protected from the principals it constrains; and a way to verify the control is in force that is separate from this report, which cannot see it. An organization policy binds neither the organization's management account nor service-linked roles, so if the flagship account is the management account A cannot stand on one; a resource control policy on the buckets is the nearer tool, and which account the flagship is sits on the list below. Who is on the named list, and who can assume each role on it, is decision 14.
+**A. Nobody else can reach a store bucket.** (Today the host role can give itself any store bucket's objects through a store user it makes: see decision 14 below. The binding this proposal asks for is exactly what that lacks.) Proposed direction: an organization policy (or, failing that, a permissions boundary required on every principal the platform creates plus an account rule) that denies S3 actions on the store bucket prefix to every principal except each bucket's own store user and a short list named by ARN. What that needs, beyond the sentence: a binding between each store identity and its one bucket, enforced (a store user under the path with a policy for another store's bucket must be impossible, not merely absent); coverage of the paths an identity policy does not show (resource policies and grants to other accounts, service-linked roles, access points); the enforcement configuration itself protected from the principals it constrains; and a way to verify the control is in force that is separate from this report, which cannot see it. An organization policy binds neither the organization's management account nor service-linked roles, and a resource control policy, the nearer tool for "nobody but X reaches these buckets", has the same two limits (it does not apply to resources in the management account and does not restrict service-linked roles); so if the flagship account is the management account neither can carry A, and which account the flagship is sits on the list below. Who is on the named list, and who can assume each role on it, is decision 14.
**B. The bucket is the one this grant made.** Proposed direction: the grant mints a bucket name with a random part recorded in its grant block before any provider call, and refuses a bucket that already exists under that name. What that needs, beyond the sentence: randomness only makes an accidental collision and a guess unlikely; an administrator who can read the grant block, or the store user's policy written before the bucket, can create the name first, so creation provenance needs an enforceable restriction on who may create buckets under the prefix, not unpredictability; an explicit treatment of the provider answering an already-owned bucket's create as success (the us-east-1 case the provisioner already notes) and of a retry after the platform's own create; and crash recovery that tells the platform's own earlier create from someone else's.
@@ -41,7 +41,7 @@ Neither is designed, and both change infrastructure or the grant walk, which nee
## Decision for Paul (item 14, reworded)
-Which principals may reach tenant store buckets besides the tenant's own store user, and who can act as each. Today the host role (it creates and deletes store buckets; it cannot read objects) and any administrator role reach every store bucket. AWS charges transfer by bucket and does not say who moved the bytes: if staff download from a tenant's bucket, that transfer lands on the tenant's bill.
+Which principals may reach tenant store buckets besides the tenant's own store user, and who can act as each. Today the host role (it creates and deletes store buckets and holds no `s3:GetObject` of its own, but `infra/modules/region-host/main.tf` lets it create a store user and write that user's policy under the store boundary, which allows `s3:GetObject` on `carlos-store-*/*`, and mint the user's keys: so it, and whoever runs code on a host as it, can give itself any store bucket's objects through a user it makes, in three calls) and any administrator role reach every store bucket. AWS charges transfer by bucket and does not say who moved the bytes: if staff download from a tenant's bucket, that transfer lands on the tenant's bill.
Proposed: only the deployment's host role and roles that need account administration to assume; named one by one, by ARN, with each role's trust policy part of the record; staff do not read tenant buckets, and if support ever requires it the platform credits the bytes. And approve the direction in A and B above, or say what should stand in their place.
diff --git a/internal/objstore/principals.go b/internal/objstore/principals.go
index 6f9fa8126..3caea8e66 100644
--- a/internal/objstore/principals.go
+++ b/internal/objstore/principals.go
@@ -72,7 +72,11 @@ import (
// - A resource pattern is read two ways, as text and as the six fields
// of an ARN with the ones an incomplete pattern leaves out as stars;
// a reach under either is reported. Which reading IAM gives an
-// incomplete pattern is unverified.
+// incomplete pattern is unverified. Under the text reading a star or
+// a question mark is free to run across colons, so a star before a
+// literal an object key can hold runs into one of this bucket's keys
+// and the grant is listed as direct, not possible: a grant on
+// "arn:aws:s3:*:123456789012:accesspoint/*" is listed as direct.
//
// What it cannot read is an error, never a shorter list: a document it
// cannot decode; a JSON member given twice anywhere in a document; a
@@ -133,7 +137,15 @@ type PrincipalGrant struct {
// this role to it and whoever can run code
// where it already runs (a host under SSM, a
// function's code, a container);
+ // "AWS:<account id>" the same as the root spelling of that account;
+ // "AWS:AROA…", "AWS:AIDA…" a principal IAM deleted after the trust
+ // policy named it (its unique id stays; a
+ // principal recreated under the same name does
+ // not match it);
// "Federated:<arn>" anyone that identity provider vouches for;
+ // "Federated:<name>" a web identity provider named by its domain
+ // (cognito-identity.amazonaws.com,
+ // accounts.google.com, ...), the same;
// "*" anyone the role's conditions, if any, let in.
AssumableBy []string `json:"assumable_by,omitempty"`
}
@@ -287,6 +299,9 @@ func (p *PrincipalReport) listAccount(ctx context.Context) (*accountPolicies, er
if err != nil {
return nil, fmt.Errorf("objstore: list the account's authorization details: %w", err)
}
+ if out == nil {
+ return nil, errors.New("objstore: list the account's authorization details: no answer and no error")
+ }
for _, u := range out.UserDetailList {
pr := inventoryPrincipal{arn: aws.ToString(u.Arn), kind: "user", name: aws.ToString(u.UserName), groups: u.GroupList, attached: attachedPolicies(u.AttachedManagedPolicies)}
if pr.inline, err = inlinePolicies(pr.arn, u.UserPolicyList); err != nil {
@@ -948,38 +963,44 @@ func arnFields(pattern string) []string {
// a bucket's objects can be read or written by a relation the ARN does
// not show (a Storage Lens configuration, for one, exports into a bucket
// of its owner's choosing). Which bucket is not in the ARN, so any of
-// them is a possible reach of every bucket. The ARN is read field by
-// field, with the fields an incomplete ARN leaves out taken as stars; an
-// incomplete ARN is read as text as well, a star free to run across
-// colons (for a complete one the two readings differ only through an
-// object key holding colons, which the direct matcher already reports);
-// a pattern that is not an ARN at all but can match anything ("*", or a
-// star before the first colon) is a possible reach too.
+// them is a possible reach of every bucket. One text test decides it, on
+// the pattern and, when it is an incomplete ARN, on its padded form: an
+// S3 resource other than a general-purpose bucket's is one whose ARN has
+// a region or an account, so the pattern must be able to match
+// "arn:<partition>:s3:" or "arn:<partition>:s3::" and then a character
+// that is not a colon (a star or a question mark can stand for that
+// character, so a wildcard that runs across a colon counts); an Object
+// Lambda resource is anything the pattern can name under
+// "arn:<partition>:s3-object-lambda:". A pattern that cannot reach those
+// prefixes as text (another service's ARN, a bucket's own ARN with its
+// empty region and account, a word) is not an access path.
func resourceIsAnAccessPath(pattern, partition string, s3, lambda bool) bool {
p := conservativePattern(pattern)
- parts := arnFields(p)
- if parts == nil {
- return strings.Contains(p, "*")
- }
- if strings.Join(parts, ":") != p && ((s3 && wildcardLive(p, "arn:"+partition+":s3:") != nil) || (lambda && wildcardLive(p, "arn:"+partition+":s3-object-lambda:") != nil)) {
- return true
- }
- if !wildcardMatch(parts[0], "arn") || !wildcardMatch(parts[1], partition) {
- return false
+ readings := []string{p}
+ if parts := arnFields(p); parts != nil && strings.Join(parts, ":") != p {
+ readings = append(readings, strings.Join(parts, ":"))
}
- svcS3 := s3 && wildcardMatch(parts[2], "s3")
- svcLambda := lambda && wildcardMatch(parts[2], "s3-object-lambda")
- if !svcS3 && !svcLambda {
- return false
+ for _, p := range readings {
+ if s3 && (continuesPastAColon(p, "arn:"+partition+":s3:") || continuesPastAColon(p, "arn:"+partition+":s3::")) {
+ return true
+ }
+ if lambda && wildcardLive(p, "arn:"+partition+":s3-object-lambda:") != nil {
+ return true
+ }
}
- if svcLambda && !svcS3 {
- // Every Object Lambda resource is an access point.
- return true
+ return false
+}
+
+// continuesPastAColon reports whether the pattern can match the prefix and
+// then one more character that is not a colon.
+func continuesPastAColon(pattern, prefix string) bool {
+ p := []rune(pattern)
+ for at := range wildcardLive(pattern, prefix) {
+ if at < len(p) && p[at] != ':' {
+ return true
+ }
}
- // A general-purpose bucket's ARN has an empty region and an empty
- // account; the direct matcher decides those. Any other S3 resource is
- // a possible reach.
- return parts[3] != "" || parts[4] != ""
+ return false
}
// actionCanBeObjectLambda reports whether an action pattern can name an
diff --git a/internal/objstore/principals_test.go b/internal/objstore/principals_test.go
index 788c7d4f3..0ee7c7c7d 100644
--- a/internal/objstore/principals_test.go
+++ b/internal/objstore/principals_test.go
@@ -339,8 +339,9 @@ func TestInventoryListsEveryPrincipalThatReachesTheName(t *testing.T) {
}
}
-// A role is reported with who its trust policy lets become it: another
-// account, a federation, anyone.
+// A role is reported with the principal values its trust policy's Allow
+// statements name: another account, a federation, anyone; not who can in
+// fact assume it.
func TestInventoryReportsWhoARoleTrusts(t *testing.T) {
role := func(name, trust string) iamtypes.RoleDetail {
r := invRole(name, map[string]string{"p": allow(`"s3:GetObject"`, `"arn:aws:s3:::carlos-store-bac-blog/*"`)})
@@ -409,9 +410,10 @@ func TestInventoryReportsWhoARoleTrusts(t *testing.T) {
// Lambda access point, an Access Grants instance, a job, a Storage Lens
// configuration) names no bucket and can reach a bucket's objects by a
// relation the ARN does not show: its principal is reported as possibly
-// reaching, marked so. A grant on another service's resource is not. A
-// pattern is read as text and as padded fields, and a reach under either
-// is reported.
+// reaching, marked so. A grant on another service's resource is not,
+// unless, read as text, its wildcards can spell an S3 ARN. A pattern is
+// read as text and as padded fields, and a reach under either is
+// reported.
func TestAnAccessPointGrantIsAPossibleReach(t *testing.T) {
const bucket = "carlos-store-bac-blog"
for name, tc := range map[string]struct {
@@ -455,6 +457,15 @@ func TestAnAccessPointGrantIsAPossibleReach(t *testing.T) {
// star can run into an object key holding ":function:thumbs": a
// direct reach, the over-report the text reading costs.)
"an incomplete ARN of another service with a star": {allow(`"s3:GetObject"`, `"arn:*:function:thumbs"`), reachDirect},
+ // A question mark can stand for a colon too, and it cannot run
+ // into this bucket's keys the way a star can: these are possible
+ // reaches that neither the field reading nor the direct matcher
+ // sees.
+ "a question mark for the colon after the service": {allow(`"s3:GetObject"`, `"arn:aws:s3?eu-west-1:123456789012:accesspoint/readers/object/a:b"`), reachPossible},
+ "a question mark for the colon, an incomplete pattern": {allow(`"s3:GetObject"`, `"arn:aws:s3?eu-west-1"`), reachPossible},
+ "a question mark for the first letter of the ARN": {allow(`"s3:PutAccessPointPolicy"`, `"?rn:aws:s3:eu-west-1:123456789012:accesspoint/readers"`), reachPossible},
+ "a word with a star, which spells no ARN": {allow(`"s3:GetObject"`, `"readers*"`), reachNone},
+ "a star for everything before the resource": {allow(`"s3:GetObject"`, `"*:accesspoint/readers"`), reachDirect},
} {
doc, err := decodePolicy(tc.doc)
if err != nil {
@@ -696,3 +707,21 @@ func TestNothingOnAWSAnswersForGrantees(t *testing.T) {
t.Fatal("the principal report answers for grantees: one listing cannot support the ledger's claim")
}
}
+
+// A page that is neither an answer nor an error (which only a stub
+// produces) is an error, not a panic.
+type nilPageInventory struct{}
+
+func (nilPageInventory) GetAccountAuthorizationDetails(context.Context, *iam.GetAccountAuthorizationDetailsInput, ...func(*iam.Options)) (*iam.GetAccountAuthorizationDetailsOutput, error) {
+ return nil, nil
+}
+
+func TestInventoryRefusesAPageThatIsNothing(t *testing.T) {
+ p, err := NewPrincipalReport(nilPageInventory{}, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if inv, err := p.BucketPrincipals(context.Background(), "carlos-store-bac-blog"); err == nil {
+ t.Fatalf("%+v with no error", inv)
+ }
+}