Files
cephrgw-operator/internal/ceph/policy_test.go
T
unkinben 54d3e38223
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
Support adopting existing radosgw buckets and users
The operator previously assumed it created every user and bucket it managed:
reconciling an existing resource could overwrite its user attributes or wipe its
bucket policy, and deleting a CRD always deleted the underlying RGW object (only
Bucket had retainOnDelete). That made taking over pre-existing radosgw state
unsafe. Make adoption first-class.

- add retainOnDelete to ObjectStoreUser and BucketAccess (dedicated users), so
  deleting the CRD orphans the RGW user instead of deleting it (symmetric with
  Bucket)
- merge bucket policy instead of replacing it: the operator marks its own
  statements with a cephrgwop* Sid and preserves any statement it does not own,
  so adopting a bucket with a hand-written policy keeps it; add Bucket
  managePolicy (default true) to opt out of policy management entirely
- only reconcile user attributes the spec sets: DisplayName when non-empty and
  Suspended is now an optional *bool, so adopting a user does not reset them
- record adoption: ObjectStoreUser/Bucket status.adopted (+ printcolumn) is true
  when the RGW object already existed on first reconcile
- add GetBucketPolicy + MergeBucketPolicy; keyed adoption detection off the
  status identity field so a Pending owner wait does not mislabel it
- regenerate CRDs/deepcopy; add docs/adoption.md and
  config/samples/05-adoption.yaml; cover the merge in policy_test.go

Claude-Session: https://claude.ai/code/session_016CEncETbf8cvy1PhsHfFHM
2026-07-25 00:15:10 +10:00

361 lines
11 KiB
Go

package ceph
import (
"encoding/json"
"strings"
"testing"
)
func TestBuildBucketPolicyEmpty(t *testing.T) {
got, err := BuildBucketPolicy("data", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "" {
t.Fatalf("expected empty policy for no grants, got %q", got)
}
}
func TestBuildBucketPolicyDeterministic(t *testing.T) {
a, err := BuildBucketPolicy("data", []Grant{
{UID: "reader", Level: LevelReadOnly},
{UID: "writer", Level: LevelReadWrite},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
b, err := BuildBucketPolicy("data", []Grant{
{UID: "writer", Level: LevelReadWrite},
{UID: "reader", Level: LevelReadOnly},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if a != b {
t.Fatalf("policy is order-dependent:\n a=%s\n b=%s", a, b)
}
}
func TestBuildBucketPolicyStructure(t *testing.T) {
raw, err := BuildBucketPolicy("data", []Grant{
{UID: "reader", Level: LevelReadOnly},
{UID: "admin", Level: LevelFull},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var doc struct {
Version string `json:"Version"`
Statement []struct {
Effect string `json:"Effect"`
Principal map[string][]string `json:"Principal"`
Action []string `json:"Action"`
Resource []string `json:"Resource"`
} `json:"Statement"`
}
if err := json.Unmarshal([]byte(raw), &doc); err != nil {
t.Fatalf("policy is not valid JSON: %v\n%s", err, raw)
}
if doc.Version != "2012-10-17" {
t.Fatalf("unexpected version %q", doc.Version)
}
// read-only -> two statements (bucket + object); full -> one statement.
if len(doc.Statement) != 3 {
t.Fatalf("expected 3 statements, got %d", len(doc.Statement))
}
var sawFullWildcard, sawReaderPrincipal bool
for _, s := range doc.Statement {
if s.Effect != "Allow" {
t.Fatalf("expected Allow effect, got %q", s.Effect)
}
for _, a := range s.Action {
if a == "s3:*" {
sawFullWildcard = true
}
}
for _, p := range s.Principal["AWS"] {
if strings.HasSuffix(p, "user/reader") {
sawReaderPrincipal = true
}
}
}
if !sawFullWildcard {
t.Fatal("full grant did not produce an s3:* action")
}
if !sawReaderPrincipal {
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 TestMergeBucketPolicyPreservesForeign(t *testing.T) {
// An existing policy with a foreign statement (unknown Sid, and a scalar
// condition value S3 allows but our typed struct does not model).
existing := `{"Version":"2012-10-17","Statement":[` +
`{"Sid":"AllowPublicRead","Effect":"Allow","Principal":"*","Action":["s3:GetObject"],` +
`"Resource":"arn:aws:s3:::data/public/*","Condition":{"Bool":{"aws:SecureTransport":"true"}}}]}`
merged, err := MergeBucketPolicy(existing, "data", []Grant{{UID: "reader", Level: LevelReadOnly}})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
doc := parsePolicyRaw(t, merged)
var sawForeign, sawManaged bool
for _, raw := range doc.Statement {
var s struct {
Sid string `json:"Sid"`
Condition map[string]map[string]any
}
if err := json.Unmarshal(raw, &s); err != nil {
t.Fatalf("statement not valid JSON: %v", err)
}
if s.Sid == "AllowPublicRead" {
sawForeign = true
// The scalar condition value must survive verbatim.
if v := s.Condition["Bool"]["aws:SecureTransport"]; v != "true" {
t.Fatalf("foreign scalar condition mangled: %v", s.Condition)
}
}
if isManagedSid(s.Sid) {
sawManaged = true
}
}
if !sawForeign {
t.Fatal("foreign statement was dropped")
}
if !sawManaged {
t.Fatal("operator statement missing from merge")
}
}
func TestMergeBucketPolicyReplacesManaged(t *testing.T) {
// Two rounds: an existing policy already carrying the operator's statements
// must not accumulate duplicates when re-merged.
first, err := MergeBucketPolicy("", "data", []Grant{{UID: "reader", Level: LevelReadOnly}})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
second, err := MergeBucketPolicy(first, "data", []Grant{{UID: "reader", Level: LevelReadOnly}})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if first != second {
t.Fatalf("re-merging its own policy was not idempotent:\n first=%s\nsecond=%s", first, second)
}
// A legacy (unprefixed) operator statement must also be recognised and
// replaced rather than preserved as foreign.
legacy := `{"Version":"2012-10-17","Statement":[` +
`{"Sid":"readonlybktreader","Effect":"Allow","Principal":{"AWS":["arn:aws:iam:::user/reader"]},` +
`"Action":["s3:ListBucket"],"Resource":["arn:aws:s3:::data"]}]}`
merged, err := MergeBucketPolicy(legacy, "data", []Grant{{UID: "reader", Level: LevelReadOnly}})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
for _, raw := range parsePolicyRaw(t, merged).Statement {
var s struct {
Sid string `json:"Sid"`
}
_ = json.Unmarshal(raw, &s)
if s.Sid == "readonlybktreader" {
t.Fatal("legacy operator statement was preserved instead of replaced")
}
}
}
func TestMergeBucketPolicyForeignOnlyKept(t *testing.T) {
existing := `{"Version":"2012-10-17","Statement":[` +
`{"Sid":"AllowPublicRead","Effect":"Allow","Principal":"*","Action":["s3:GetObject"],` +
`"Resource":"arn:aws:s3:::data/*"}]}`
// No grants: the operator adds nothing but must not wipe the foreign policy.
merged, err := MergeBucketPolicy(existing, "data", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if merged == "" {
t.Fatal("merge cleared a policy that had a foreign statement")
}
if len(parsePolicyRaw(t, merged).Statement) != 1 {
t.Fatalf("expected the single foreign statement, got %s", merged)
}
}
func TestMergeBucketPolicyEmpty(t *testing.T) {
merged, err := MergeBucketPolicy("", "data", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if merged != "" {
t.Fatalf("expected empty policy, got %q", merged)
}
}
// parsePolicyRaw parses a policy keeping statements as raw JSON.
func parsePolicyRaw(t *testing.T, raw string) struct {
Version string `json:"Version"`
Statement []json.RawMessage `json:"Statement"`
} {
t.Helper()
var doc struct {
Version string `json:"Version"`
Statement []json.RawMessage `json:"Statement"`
}
if err := json.Unmarshal([]byte(raw), &doc); err != nil {
t.Fatalf("policy is not valid JSON: %v\n%s", err, raw)
}
return doc
}
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)
}
}