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
+66
View File
@@ -45,6 +45,72 @@ type BucketAccessSpec struct {
// dedicated user it creates (UserRef empty). Defaults to "<name>-rgw".
// +optional
SecretName string `json:"secretName,omitempty"`
// Paths optionally scopes object-level access to these key prefixes within
// the bucket; each becomes the resource "<bucket>/<prefix>*". Empty grants
// the whole bucket. The bucket-level ListBucket action always applies to the
// whole bucket. Ignored when RawStatements is set.
// +optional
Paths []string `json:"paths,omitempty"`
// Actions optionally overrides the S3 actions granted by Level. When set,
// exactly these actions are granted, on the bucket and its (optionally
// prefixed) objects. Ignored when RawStatements is set.
// +optional
Actions []string `json:"actions,omitempty"`
// Conditions optionally restricts when the grant applies (e.g. source IPs,
// TLS required). Ignored when RawStatements is set.
// +optional
Conditions *AccessConditions `json:"conditions,omitempty"`
// RawStatements is an escape hatch for arbitrary S3 policy statements, merged
// into the bucket policy for this grant's principal. When set, Level,
// Actions, Paths and Conditions on this object are ignored; the operator only
// fills in the Principal (this grant's user) when a statement omits one.
// +optional
RawStatements []PolicyStatement `json:"rawStatements,omitempty"`
}
// AccessConditions restricts when a grant applies. Each field maps to an S3
// policy condition and, when several are set, all must hold (they are AND'd).
type AccessConditions struct {
// SourceIPs restricts the grant to requests from these CIDRs (or single
// addresses), via the S3 aws:SourceIp condition.
// +optional
SourceIPs []string `json:"sourceIPs,omitempty"`
// SecureTransportOnly requires the request to use TLS, via the S3
// aws:SecureTransport condition.
// +optional
SecureTransportOnly bool `json:"secureTransportOnly,omitempty"`
}
// PolicyStatement is a raw S3 bucket-policy statement, exposed for grants that
// need control beyond Level/Actions/Paths/Conditions.
type PolicyStatement struct {
// Sid is an optional statement id. The operator derives one when empty.
// +optional
Sid string `json:"sid,omitempty"`
// Effect is Allow or Deny. Defaults to Allow.
// +kubebuilder:validation:Enum=Allow;Deny
// +kubebuilder:default=Allow
// +optional
Effect string `json:"effect,omitempty"`
// Actions are the S3 actions the statement covers (e.g. s3:GetObject).
Actions []string `json:"actions"`
// Resources are S3 resource ARNs, or bucket-relative key prefixes when they
// do not start with "arn:". Empty means the whole bucket and its objects.
// +optional
Resources []string `json:"resources,omitempty"`
// Conditions is the raw S3 condition block: operator -> condition key ->
// values, e.g. {"IpAddress": {"aws:SourceIp": ["10.0.0.0/8"]}}.
// +optional
Conditions map[string]map[string][]string `json:"conditions,omitempty"`
}
// BucketAccessStatus reports observed grant state.
+95 -1
View File
@@ -9,6 +9,26 @@ import (
runtime "k8s.io/apimachinery/pkg/runtime"
)
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *AccessConditions) DeepCopyInto(out *AccessConditions) {
*out = *in
if in.SourceIPs != nil {
in, out := &in.SourceIPs, &out.SourceIPs
*out = make([]string, len(*in))
copy(*out, *in)
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AccessConditions.
func (in *AccessConditions) DeepCopy() *AccessConditions {
if in == nil {
return nil
}
out := new(AccessConditions)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *Bucket) DeepCopyInto(out *Bucket) {
*out = *in
@@ -41,7 +61,7 @@ func (in *BucketAccess) DeepCopyInto(out *BucketAccess) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
out.Spec = in.Spec
in.Spec.DeepCopyInto(&out.Spec)
in.Status.DeepCopyInto(&out.Status)
}
@@ -98,6 +118,28 @@ func (in *BucketAccessList) DeepCopyObject() runtime.Object {
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *BucketAccessSpec) DeepCopyInto(out *BucketAccessSpec) {
*out = *in
if in.Paths != nil {
in, out := &in.Paths, &out.Paths
*out = make([]string, len(*in))
copy(*out, *in)
}
if in.Actions != nil {
in, out := &in.Actions, &out.Actions
*out = make([]string, len(*in))
copy(*out, *in)
}
if in.Conditions != nil {
in, out := &in.Conditions, &out.Conditions
*out = new(AccessConditions)
(*in).DeepCopyInto(*out)
}
if in.RawStatements != nil {
in, out := &in.RawStatements, &out.RawStatements
*out = make([]PolicyStatement, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BucketAccessSpec.
@@ -349,6 +391,58 @@ func (in *ObjectStoreUserStatus) DeepCopy() *ObjectStoreUserStatus {
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *PolicyStatement) DeepCopyInto(out *PolicyStatement) {
*out = *in
if in.Actions != nil {
in, out := &in.Actions, &out.Actions
*out = make([]string, len(*in))
copy(*out, *in)
}
if in.Resources != nil {
in, out := &in.Resources, &out.Resources
*out = make([]string, len(*in))
copy(*out, *in)
}
if in.Conditions != nil {
in, out := &in.Conditions, &out.Conditions
*out = make(map[string]map[string][]string, len(*in))
for key, val := range *in {
var outVal map[string][]string
if val == nil {
(*out)[key] = nil
} else {
inVal := (*in)[key]
in, out := &inVal, &outVal
*out = make(map[string][]string, len(*in))
for key, val := range *in {
var outVal []string
if val == nil {
(*out)[key] = nil
} else {
inVal := (*in)[key]
in, out := &inVal, &outVal
*out = make([]string, len(*in))
copy(*out, *in)
}
(*out)[key] = outVal
}
}
(*out)[key] = outVal
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PolicyStatement.
func (in *PolicyStatement) DeepCopy() *PolicyStatement {
if in == nil {
return nil
}
out := new(PolicyStatement)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *Quota) DeepCopyInto(out *Quota) {
*out = *in