466514063a
The operator drove the Ceph manager dashboard REST API to manage RGW users, buckets and policies. That coupled it to a dashboard login, the dashboard's RGW wiring, and the dashboard's bucket API surface. Rebuild the Ceph integration to talk directly to radosgw the way the CLI does, using native Go libraries, while keeping every operator capability identical. The exported surface of internal/ceph is unchanged, so the three controllers and cmd/operator's structure are untouched (bar the CEPH_RGW_* config plumbing). - replace the internal/ceph client internals with github.com/ceph/go-ceph rgw/admin (Admin Ops API) for users, keys, quotas and bucket info/removal - add github.com/aws/aws-sdk-go-v2 S3 client for bucket create, versioning, policy, tagging and object lock, signed as the bucket owner - map go-ceph admin.ErrNoSuch*/ErrUserExists and smithy APIError codes into IsNotFound/IsConflict so controller create-vs-update branching is preserved - set S3 path-style addressing and WhenRequired checksum modes for RGW - delete the hand-rolled dashboard client, token auth and JSON plumbing - keep policy.go/BuildBucketPolicy/BuildTagJSON as pure builders - replace the client tests with NewClient validation and error-classifier tests - keep CGO_ENABLED=0 distroless: only go-ceph's pure-Go rgw/admin is imported - switch env/config to CEPH_RGW_* (endpoint, admin endpoint, access/secret key, region, CA, insecure) and update the deployment manifest - rewrite README and docs/ceph-setup.md for the single RGW admin user (caps users=*;buckets=*), keeping Vault/VSO as the primary credential source Claude-Session: https://claude.ai/code/session_016CEncETbf8cvy1PhsHfFHM
167 lines
4.0 KiB
Go
167 lines
4.0 KiB
Go
package ceph
|
|
|
|
import (
|
|
"encoding/json"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// Access levels mirrored from the API package to avoid an import cycle; the
|
|
// controllers translate their typed level into these strings.
|
|
const (
|
|
LevelReadOnly = "read-only"
|
|
LevelReadWrite = "read-write"
|
|
LevelFull = "full"
|
|
)
|
|
|
|
// Grant couples an RGW user id with the access level to grant it on a bucket.
|
|
type Grant struct {
|
|
UID string
|
|
Level string
|
|
}
|
|
|
|
type policyDocument struct {
|
|
Version string `json:"Version"`
|
|
Statement []policyStatement `json:"Statement"`
|
|
}
|
|
|
|
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"`
|
|
}
|
|
|
|
// bucket-level and object-level S3 actions per access level.
|
|
var bucketActions = map[string][]string{
|
|
LevelReadOnly: {
|
|
"s3:ListBucket",
|
|
"s3:GetBucketLocation",
|
|
"s3:ListBucketVersions",
|
|
},
|
|
LevelReadWrite: {
|
|
"s3:ListBucket",
|
|
"s3:GetBucketLocation",
|
|
"s3:ListBucketVersions",
|
|
"s3:ListBucketMultipartUploads",
|
|
},
|
|
}
|
|
|
|
var objectActions = map[string][]string{
|
|
LevelReadOnly: {
|
|
"s3:GetObject",
|
|
"s3:GetObjectVersion",
|
|
"s3:GetObjectTagging",
|
|
},
|
|
LevelReadWrite: {
|
|
"s3:GetObject",
|
|
"s3:GetObjectVersion",
|
|
"s3:GetObjectTagging",
|
|
"s3:PutObject",
|
|
"s3:PutObjectTagging",
|
|
"s3:DeleteObject",
|
|
"s3:DeleteObjectVersion",
|
|
"s3:AbortMultipartUpload",
|
|
"s3:ListMultipartUploadParts",
|
|
},
|
|
}
|
|
|
|
// BuildBucketPolicy renders a deterministic S3 bucket policy granting each
|
|
// principal its requested level. 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 {
|
|
return "", nil
|
|
}
|
|
|
|
sorted := make([]Grant, len(grants))
|
|
copy(sorted, grants)
|
|
sort.Slice(sorted, func(i, j int) bool {
|
|
if sorted[i].UID == sorted[j].UID {
|
|
return sorted[i].Level < sorted[j].Level
|
|
}
|
|
return sorted[i].UID < sorted[j].UID
|
|
})
|
|
|
|
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},
|
|
},
|
|
)
|
|
}
|
|
}
|
|
|
|
b, err := json.Marshal(doc)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return string(b), nil
|
|
}
|
|
|
|
// sid builds a policy statement id that only contains characters S3 accepts.
|
|
func sid(prefix, uid string) string {
|
|
var b strings.Builder
|
|
b.WriteString(strings.ReplaceAll(prefix, "-", ""))
|
|
for _, r := range uid {
|
|
switch {
|
|
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
|
|
b.WriteRune(r)
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// BuildTagJSON renders bucket tags as a {Key,Value} JSON list, the intermediate
|
|
// form SetBucketTags parses and re-encodes into the S3 Tagging XML document.
|
|
func BuildTagJSON(tags map[string]string) (string, error) {
|
|
if len(tags) == 0 {
|
|
return "", nil
|
|
}
|
|
keys := make([]string, 0, len(tags))
|
|
for k := range tags {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
type kv struct {
|
|
Key string `json:"Key"`
|
|
Value string `json:"Value"`
|
|
}
|
|
out := make([]kv, 0, len(keys))
|
|
for _, k := range keys {
|
|
out = append(out, kv{Key: k, Value: tags[k]})
|
|
}
|
|
b, err := json.Marshal(out)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return string(b), nil
|
|
}
|