Skip to content

Commit 7334f48

Browse files
authored
refactor(blobmanager/s3accesspoint): slim AssumeRole session policy (#3143)
1 parent 25cd039 commit 7334f48

3 files changed

Lines changed: 40 additions & 29 deletions

File tree

pkg/blobmanager/s3accesspoint/backend.go

Lines changed: 17 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -296,13 +296,14 @@ func (p *sessionCredentialsProvider) Retrieve(ctx context.Context) (aws.Credenti
296296
return p.ambientCreds.Retrieve(ctx)
297297
}
298298

299-
// Session policy intersects with the base role's permissions; even
300-
// if the role grants accesspoint/*, this session can only touch the
301-
// caller's AP and prefix. The prefix is the requesting-org UUID
302-
// straight from ctx — same source as the session name — so a
303-
// tampered AccessPointARN in the secret blob can't widen the prefix
304-
// scope to escape into another tenant's namespace.
305-
sessionPolicy := buildSessionPolicy(p.creds.AccessPointARN, info.OrgID)
299+
// Session policy intersects with the base role's permissions and
300+
// pins this session to the caller's AP ARN. Cross-tenant defense
301+
// against a tampered AccessPointARN in the secret blob lives in the
302+
// AP's resource policy (aws:userid StringEquals on the role session
303+
// name minted from the request-context org UUID), not here — keeping
304+
// the inline policy small leaves headroom in STS's packed-policy
305+
// budget for tags inherited from the caller principal.
306+
sessionPolicy := buildSessionPolicy(p.creds.AccessPointARN)
306307

307308
out, err := p.stsClient.AssumeRole(ctx, &sts.AssumeRoleInput{
308309
RoleArn: aws.String(p.creds.BaseRoleARN),
@@ -334,15 +335,15 @@ func roleSessionName(orgUUID string) string {
334335
return "cas-" + orgUUID
335336
}
336337

337-
// buildSessionPolicy returns an IAM policy document that allows only the
338-
// operations the backend actually performs, and only against this
339-
// tenant's AP + key prefix. The Resource ARNs use the AP form
340-
// "${apARN}/object/${keyPrefix}/*".
341-
func buildSessionPolicy(apARN, keyPrefix string) string {
342-
// Minimal, hand-written JSON — keeping it small reduces request
343-
// payload (STS limits session policies to 2048 chars by default).
344-
return fmt.Sprintf(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:GetObject","s3:PutObject","s3:DeleteObject","s3:GetObjectAttributes"],"Resource":"%s/object/%s/*"}]}`,
345-
apARN, keyPrefix)
338+
// buildSessionPolicy returns an IAM policy document allowing only the
339+
// S3 actions the backend actually performs (Get/Head/Put — HeadObject
340+
// is authorized as s3:GetObject) and scoped to this tenant's AP ARN.
341+
// Cross-tenant isolation is enforced by the AP resource policy's
342+
// aws:userid check against the role session name, not by this policy;
343+
// keeping the inline document minimal preserves headroom in the STS
344+
// packed-policy budget against tags inherited from the caller.
345+
func buildSessionPolicy(apARN string) string {
346+
return fmt.Sprintf(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:GetObject","s3:PutObject"],"Resource":"%s/object/*"}]}`, apARN)
346347
}
347348

348349
// hexSha256ToBinaryB64 decodes the hex sha and re-encodes as base64. S3

pkg/blobmanager/s3accesspoint/backend_test.go

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -90,21 +90,27 @@ func TestBackend_KeyDerivedFromRequestingOrg(t *testing.T) {
9090
require.ErrorIs(t, err, ErrMissingRequestingOrg)
9191
}
9292

93-
// TestSessionPolicy_ScopesToTenantPrefix locks down the session-policy
94-
// generator: the IAM policy minted at AssumeRole time must reference
95-
// both the AP ARN and the tenant key prefix, so a leaked token can't
96-
// touch keys outside its tenant's namespace.
97-
func TestSessionPolicy_ScopesToTenantPrefix(t *testing.T) {
93+
// TestSessionPolicy_ScopesToAccessPoint locks down the session-policy
94+
// generator: the IAM policy minted at AssumeRole time must scope to the
95+
// AP ARN (the tenant boundary lives in the AP resource policy, not
96+
// here), and must not include S3 actions the backend never calls — that
97+
// keeps the inline policy small so STS's packed-policy budget has
98+
// headroom for tags inherited from the caller principal.
99+
func TestSessionPolicy_ScopesToAccessPoint(t *testing.T) {
98100
t.Parallel()
99101

100-
policy := buildSessionPolicy("arn:aws:s3:us-east-1:111:accesspoint/ap-a", "org/A")
102+
policy := buildSessionPolicy("arn:aws:s3:us-east-1:111:accesspoint/ap-a")
101103

102-
assert.Contains(t, policy, `"arn:aws:s3:us-east-1:111:accesspoint/ap-a/object/org/A/*"`,
103-
"policy Resource must be the AP ARN + tenant prefix")
104+
assert.Contains(t, policy, `"arn:aws:s3:us-east-1:111:accesspoint/ap-a/object/*"`,
105+
"policy Resource must be the AP ARN scoped to /object/*")
104106
assert.NotContains(t, policy, `"*"`,
105107
"session policy must not wildcard the Resource")
106108
assert.Contains(t, policy, `"s3:GetObject"`)
107109
assert.Contains(t, policy, `"s3:PutObject"`)
110+
assert.NotContains(t, policy, `"s3:DeleteObject"`,
111+
"backend never deletes — re-adding s3:DeleteObject grows the packed-policy footprint without need")
112+
assert.NotContains(t, policy, `"s3:GetObjectAttributes"`,
113+
"backend never calls GetObjectAttributes — re-adding it grows the packed-policy footprint without need")
108114
}
109115

110116
// TestRoleSessionName_DerivedFromOrg pins the session-name shape that

pkg/blobmanager/s3accesspoint/provider.go

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,16 @@
2424
// resource policy enforces a StringEquals on aws:userid so that a
2525
// session minted for org A cannot read or write to org B's AP — even if
2626
// org A's secret blob has been tampered with to point at org B's ARN.
27+
// This is the actual cross-tenant boundary.
2728
// 3. A per-tenant key prefix derived from the requesting org UUID: every
28-
// object is keyed as <orgUUID>/sha256:<digest> and the AssumeRole
29-
// session policy's Resource is scoped to ${apARN}/object/<orgUUID>/*.
30-
// The prefix shares its source of truth with the session name, so a
31-
// tampered secret cannot reroute a tenant's writes into a different
32-
// namespace.
29+
// object is keyed as <orgUUID>/sha256:<digest>, sharing its source of
30+
// truth (the request-context org claim) with the role session name.
31+
// This avoids cross-tenant collisions at the bucket layer; it is not
32+
// load-bearing for tenant isolation, which is enforced by (1) and (2).
33+
// The AssumeRole session policy's Resource scopes to the AP ARN only
34+
// (${apARN}/object/*), keeping the inline document small so STS's
35+
// packed-policy budget isn't consumed by chainloop before tags
36+
// inherited from the caller principal are even accounted for.
3337
//
3438
// The session name MUST come from the request context, not from the secret
3539
// blob: a secrets-store compromise alone must not let an attacker reroute

0 commit comments

Comments
 (0)