54d3e38223
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
361 lines
11 KiB
Go
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)
|
|
}
|
|
}
|