Add fine-grained bucket access: paths, actions, conditions, raw
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful

The BucketAccess model only offered three coarse levels (read-only/read-write/
full) applied to the whole bucket. Real grants often need to be scoped to a key
prefix, limited to a source network or TLS, restricted to specific actions, or
expressed as an arbitrary S3 statement. RGW (Reef 18.2+/Squid) honours the S3
bucket-policy features to do all of this; expose them on BucketAccess while
keeping the level as the ergonomic default.

- add BucketAccess spec fields: paths (key-prefix scoping), actions (action
  override), conditions (sourceIPs + secureTransportOnly), rawStatements
  (arbitrary S3 statements with the principal injected)
- extend ceph.Grant + BuildBucketPolicy to render prefixed object resources,
  custom-action statements, S3 condition blocks, and raw statements, keeping
  output deterministic (sorted, stable sids)
- translate the new spec fields into grants in the Bucket controller and
  fingerprint grants so distinct fine-grained BucketAccess objects no longer
  collapse on UID+level alone
- regenerate deepcopy + CRDs; add config/samples/04-access-fine-grained.yaml
- cover paths, action override, conditions, raw statements and determinism in
  policy_test.go; document the fields in the README

Claude-Session: https://claude.ai/code/session_016CEncETbf8cvy1PhsHfFHM
This commit is contained in:
2026-07-24 22:48:20 +10:00
parent 466514063a
commit 4b0430f0df
10 changed files with 788 additions and 41 deletions
+167 -36
View File
@@ -3,6 +3,7 @@ package ceph
import (
"encoding/json"
"sort"
"strconv"
"strings"
)
@@ -14,10 +15,40 @@ const (
LevelFull = "full"
)
// Grant couples an RGW user id with the access level to grant it on a bucket.
// GrantConditions restricts when a grant's statements apply. The zero value adds
// no conditions.
type GrantConditions struct {
// SourceIPs restricts the grant to these CIDRs (S3 aws:SourceIp).
SourceIPs []string
// SecureTransportOnly requires TLS (S3 aws:SecureTransport).
SecureTransportOnly bool
}
// RawStatement is a caller-supplied S3 policy statement for a grant.
type RawStatement struct {
Sid string
Effect string
Actions []string
Resources []string
Condition map[string]map[string][]string
}
// Grant couples an RGW user id with the access it should have on a bucket. The
// simple form is a Level; Paths, Actions and Conditions refine it, and Raw
// replaces it entirely with caller-supplied statements.
type Grant struct {
UID string
Level string
// Paths scopes object-level access to these key prefixes; empty = whole
// bucket.
Paths []string
// Actions overrides the level's action set; empty = derive from Level.
Actions []string
// Conditions optionally restricts when the grant applies.
Conditions *GrantConditions
// Raw, when non-empty, replaces Level/Actions/Paths/Conditions with these
// statements (the operator still fills in a Principal when one is omitted).
Raw []RawStatement
}
type policyDocument struct {
@@ -26,11 +57,12 @@ type policyDocument struct {
}
type policyStatement struct {
Sid string `json:"Sid"`
Effect string `json:"Effect"`
Principal map[string][]string `json:"Principal"`
Action []string `json:"Action"`
Resource []string `json:"Resource"`
Sid string `json:"Sid,omitempty"`
Effect string `json:"Effect"`
Principal map[string][]string `json:"Principal,omitempty"`
Action []string `json:"Action"`
Resource []string `json:"Resource"`
Condition map[string]map[string][]string `json:"Condition,omitempty"`
}
// bucket-level and object-level S3 actions per access level.
@@ -68,7 +100,7 @@ var objectActions = map[string][]string{
}
// BuildBucketPolicy renders a deterministic S3 bucket policy granting each
// principal its requested level. It returns "" when there are no grants so the
// principal its requested access. It returns "" when there are no grants so the
// caller can clear the policy.
func BuildBucketPolicy(bucket string, grants []Grant) (string, error) {
if len(grants) == 0 {
@@ -85,38 +117,10 @@ func BuildBucketPolicy(bucket string, grants []Grant) (string, error) {
})
bucketARN := "arn:aws:s3:::" + bucket
objectARN := bucketARN + "/*"
doc := policyDocument{Version: "2012-10-17"}
for _, g := range sorted {
principal := map[string][]string{"AWS": {"arn:aws:iam:::user/" + g.UID}}
switch g.Level {
case LevelFull:
doc.Statement = append(doc.Statement, policyStatement{
Sid: sid("full", g.UID),
Effect: "Allow",
Principal: principal,
Action: []string{"s3:*"},
Resource: []string{bucketARN, objectARN},
})
default:
doc.Statement = append(doc.Statement,
policyStatement{
Sid: sid(g.Level+"-bkt", g.UID),
Effect: "Allow",
Principal: principal,
Action: bucketActions[g.Level],
Resource: []string{bucketARN},
},
policyStatement{
Sid: sid(g.Level+"-obj", g.UID),
Effect: "Allow",
Principal: principal,
Action: objectActions[g.Level],
Resource: []string{objectARN},
},
)
}
doc.Statement = append(doc.Statement, statementsForGrant(bucketARN, g)...)
}
b, err := json.Marshal(doc)
@@ -126,6 +130,133 @@ func BuildBucketPolicy(bucket string, grants []Grant) (string, error) {
return string(b), nil
}
// statementsForGrant renders the policy statements for a single grant.
func statementsForGrant(bucketARN string, g Grant) []policyStatement {
principal := map[string][]string{"AWS": {"arn:aws:iam:::user/" + g.UID}}
if len(g.Raw) > 0 {
out := make([]policyStatement, 0, len(g.Raw))
for i, rs := range g.Raw {
st := policyStatement{
Sid: firstNonEmpty(rs.Sid, sid("raw", g.UID)+strconv.Itoa(i)),
Effect: firstNonEmpty(rs.Effect, "Allow"),
Principal: principal,
Action: rs.Actions,
Resource: resolveResources(bucketARN, rs.Resources),
Condition: rs.Condition,
}
out = append(out, st)
}
return out
}
cond := buildCondition(g.Conditions)
objectARNs := objectResources(bucketARN, g.Paths)
if len(g.Actions) > 0 {
return []policyStatement{{
Sid: sid("custom", g.UID),
Effect: "Allow",
Principal: principal,
Action: g.Actions,
Resource: append([]string{bucketARN}, objectARNs...),
Condition: cond,
}}
}
if g.Level == LevelFull {
return []policyStatement{{
Sid: sid("full", g.UID),
Effect: "Allow",
Principal: principal,
Action: []string{"s3:*"},
Resource: append([]string{bucketARN}, objectARNs...),
Condition: cond,
}}
}
return []policyStatement{
{
Sid: sid(g.Level+"-bkt", g.UID),
Effect: "Allow",
Principal: principal,
Action: bucketActions[g.Level],
Resource: []string{bucketARN},
Condition: cond,
},
{
Sid: sid(g.Level+"-obj", g.UID),
Effect: "Allow",
Principal: principal,
Action: objectActions[g.Level],
Resource: objectARNs,
Condition: cond,
},
}
}
// objectResources renders the object-level resource ARNs for a grant: the whole
// bucket ("<bucket>/*") when no paths are given, or one "<bucket>/<prefix>*" per
// prefix (deduplicated and sorted for determinism).
func objectResources(bucketARN string, paths []string) []string {
if len(paths) == 0 {
return []string{bucketARN + "/*"}
}
seen := map[string]struct{}{}
out := make([]string, 0, len(paths))
for _, p := range paths {
p = strings.TrimPrefix(p, "/")
arn := bucketARN + "/" + p + "*"
if _, dup := seen[arn]; dup {
continue
}
seen[arn] = struct{}{}
out = append(out, arn)
}
sort.Strings(out)
return out
}
// resolveResources renders raw-statement resources: entries that already look
// like ARNs pass through verbatim; bucket-relative prefixes become
// "<bucket>/<prefix>*". An empty list defaults to the whole bucket and objects.
func resolveResources(bucketARN string, resources []string) []string {
if len(resources) == 0 {
return []string{bucketARN, bucketARN + "/*"}
}
out := make([]string, 0, len(resources))
for _, r := range resources {
switch {
case strings.HasPrefix(r, "arn:"):
out = append(out, r)
case r == "" || r == "/":
out = append(out, bucketARN+"/*")
default:
out = append(out, bucketARN+"/"+strings.TrimPrefix(r, "/")+"*")
}
}
return out
}
// buildCondition renders the S3 condition block for a grant, or nil when there
// is nothing to add.
func buildCondition(c *GrantConditions) map[string]map[string][]string {
if c == nil {
return nil
}
cond := map[string]map[string][]string{}
if len(c.SourceIPs) > 0 {
cond["IpAddress"] = map[string][]string{"aws:SourceIp": c.SourceIPs}
}
if c.SecureTransportOnly {
cond["Bool"] = map[string][]string{"aws:SecureTransport": {"true"}}
}
if len(cond) == 0 {
return nil
}
return cond
}
// sid builds a policy statement id that only contains characters S3 accepts.
func sid(prefix, uid string) string {
var b strings.Builder
+152
View File
@@ -88,3 +88,155 @@ func TestBuildBucketPolicyStructure(t *testing.T) {
t.Fatal("reader principal ARN missing")
}
}
// parsedPolicy is a fuller parse of a rendered policy for the fine-grained tests.
type parsedPolicy struct {
Statement []struct {
Sid string `json:"Sid"`
Effect string `json:"Effect"`
Principal map[string][]string `json:"Principal"`
Action []string `json:"Action"`
Resource []string `json:"Resource"`
Condition map[string]map[string][]string `json:"Condition"`
} `json:"Statement"`
}
func parsePolicy(t *testing.T, raw string) parsedPolicy {
t.Helper()
var doc parsedPolicy
if err := json.Unmarshal([]byte(raw), &doc); err != nil {
t.Fatalf("policy is not valid JSON: %v\n%s", err, raw)
}
return doc
}
func TestBuildBucketPolicyPaths(t *testing.T) {
raw, err := BuildBucketPolicy("data", []Grant{
{UID: "reader", Level: LevelReadOnly, Paths: []string{"team-a/", "/shared/inbox/"}},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
doc := parsePolicy(t, raw)
var objResources []string
for _, s := range doc.Statement {
for _, a := range s.Action {
if a == "s3:GetObject" {
objResources = s.Resource
}
}
}
want := map[string]bool{
"arn:aws:s3:::data/shared/inbox/*": false,
"arn:aws:s3:::data/team-a/*": false,
}
if len(objResources) != len(want) {
t.Fatalf("expected %d object resources, got %v", len(want), objResources)
}
for _, r := range objResources {
if _, ok := want[r]; !ok {
t.Fatalf("unexpected object resource %q (leading slash not trimmed?)", r)
}
want[r] = true
}
for r, seen := range want {
if !seen {
t.Fatalf("missing object resource %q", r)
}
}
}
func TestBuildBucketPolicyActionsOverride(t *testing.T) {
raw, err := BuildBucketPolicy("data", []Grant{
{UID: "svc", Level: LevelReadOnly, Actions: []string{"s3:GetObject", "s3:PutObject"}},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
doc := parsePolicy(t, raw)
if len(doc.Statement) != 1 {
t.Fatalf("expected a single custom-action statement, got %d", len(doc.Statement))
}
s := doc.Statement[0]
if len(s.Action) != 2 || s.Action[0] != "s3:GetObject" || s.Action[1] != "s3:PutObject" {
t.Fatalf("actions not taken verbatim: %v", s.Action)
}
// Custom-action statement lists both the bucket and object resources.
if len(s.Resource) != 2 || s.Resource[0] != "arn:aws:s3:::data" || s.Resource[1] != "arn:aws:s3:::data/*" {
t.Fatalf("unexpected resources: %v", s.Resource)
}
}
func TestBuildBucketPolicyConditions(t *testing.T) {
raw, err := BuildBucketPolicy("data", []Grant{
{UID: "reader", Level: LevelReadOnly, Conditions: &GrantConditions{
SourceIPs: []string{"10.0.0.0/8"},
SecureTransportOnly: true,
}},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
doc := parsePolicy(t, raw)
for _, s := range doc.Statement {
if s.Condition == nil {
t.Fatalf("statement %q missing condition block", s.Sid)
}
if ip := s.Condition["IpAddress"]["aws:SourceIp"]; len(ip) != 1 || ip[0] != "10.0.0.0/8" {
t.Fatalf("unexpected SourceIp condition: %v", s.Condition["IpAddress"])
}
if tls := s.Condition["Bool"]["aws:SecureTransport"]; len(tls) != 1 || tls[0] != "true" {
t.Fatalf("unexpected SecureTransport condition: %v", s.Condition["Bool"])
}
}
}
func TestBuildBucketPolicyRawStatements(t *testing.T) {
raw, err := BuildBucketPolicy("data", []Grant{
{UID: "svc", Level: LevelReadOnly, Raw: []RawStatement{
{
Effect: "Deny",
Actions: []string{"s3:DeleteObject"},
Resources: []string{"locked/", "arn:aws:s3:::other/*"},
},
}},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
doc := parsePolicy(t, raw)
if len(doc.Statement) != 1 {
t.Fatalf("expected 1 raw statement, got %d", len(doc.Statement))
}
s := doc.Statement[0]
if s.Effect != "Deny" {
t.Fatalf("raw effect not honoured: %q", s.Effect)
}
// The operator fills in the principal; bucket-relative prefixes are expanded
// while explicit ARNs pass through.
if len(s.Principal["AWS"]) != 1 || !strings.HasSuffix(s.Principal["AWS"][0], "user/svc") {
t.Fatalf("raw statement principal not injected: %v", s.Principal)
}
if len(s.Resource) != 2 || s.Resource[0] != "arn:aws:s3:::data/locked/*" || s.Resource[1] != "arn:aws:s3:::other/*" {
t.Fatalf("unexpected raw resources: %v", s.Resource)
}
}
func TestBuildBucketPolicyFineGrainedDeterministic(t *testing.T) {
grants := []Grant{
{UID: "reader", Level: LevelReadOnly, Paths: []string{"a/", "b/"}},
{UID: "svc", Level: LevelReadWrite, Conditions: &GrantConditions{SecureTransportOnly: true}},
}
a, err := BuildBucketPolicy("data", grants)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
b, err := BuildBucketPolicy("data", []Grant{grants[1], grants[0]})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if a != b {
t.Fatalf("fine-grained policy is order-dependent:\n a=%s\n b=%s", a, b)
}
}