Files
cephrgw-operator/internal/ceph/policy.go
T
benvin 1ea1713d6e Initial cephrgw-operator: Ceph RGW buckets & keys via dashboard API
Adds a Kubernetes operator that provisions Ceph RGW (S3) buckets and
access keys declaratively through the Ceph manager dashboard REST API.

Three CRDs in group ceph.unkin.net/v1alpha1:
- ObjectStoreUser: creates an RGW user, delivers its key pair to a Secret
- Bucket: creates an S3 bucket owned by an ObjectStoreUser; owns the
  bucket's aggregate S3 policy (union of all BucketAccess grants)
- BucketAccess: grants read-only/read-write/full access, provisioning a
  dedicated user (or reusing a referenced one) and delivering RW/RO keys

The internal/ceph client wraps the dashboard /api/auth, /api/rgw/user and
/api/rgw/bucket endpoints with lazy token auth and re-auth on 401. Bucket
policies are rendered deterministically and applied via the bucket
policy API (Reef 18.2+). Credentials come from the cephrgw-credentials
Secret via env. Includes generated CRDs/RBAC, samples, kind manifests,
Woodpecker CI, and docs/ceph-setup.md covering the required Ceph
dashboard account, RGW wiring and permissions.
2026-07-18 00:07:22 +10:00

166 lines
3.9 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 in the JSON form the dashboard expects.
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
}