From b3e69bca2fd9b94a078e635bf4fac68301fdf1e6 Mon Sep 17 00:00:00 2001 From: Feng Ruohang Date: Tue, 10 Nov 2026 09:01:26 +0800 Subject: [PATCH 1/5] fix(pools): keep rejected names out of a single-pool bulk delete DeleteObjects checks every name with checkDelObjArgs, but a single pool still sent all entries to the drives and cleared the error of an entry that a drive reported as found. Local drives join the name to the bucket directory, so a name with a ".." segment reached another bucket or the meta bucket. An S3 DeleteObjects caller allowed to delete in one bucket could remove a version elsewhere, including one under Object Lock retention, and from a versioned bucket could write a delete marker on an object of another bucket or on stored bucket metadata. The bulk lock was taken on the normalized name as well, that is on the other object. Entries that fail the check now keep their error, and the rest of the request runs as a request of its own, before any lock or lookup. A request whose names are all valid does not enter the new code. More than one pool already skipped such entries. Co-Authored-By: Claude Fable 5.2 Signed-off-by: Feng Ruohang --- CHANGELOG.md | 13 +++ cmd/bulk-delete-invalid-name_test.go | 156 +++++++++++++++++++++++++++ cmd/erasure-server-pool.go | 22 ++++ 3 files changed, 191 insertions(+) create mode 100644 cmd/bulk-delete-invalid-name_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index eec03251d..44d6081b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## Unreleased + +- S3 `DeleteObjects` on a single pool stops at an entry whose name fails the + object name check: the entry returns `XMinioInvalidObjectName` and is not + locked or sent to a drive, as with a single-object DELETE and with more than + one pool. Until now such an entry still reached the drives of the node, where + a name with a `..` segment was joined to the request bucket. A caller allowed + to delete in one bucket could remove a version in another bucket, including + one under Object Lock retention, and from a versioned bucket could put a + delete marker on an object of another bucket or on stored system metadata + such as a bucket's configuration. The other entries of a request are deleted + as before. + ## RELEASE.2026-11-09T00-00-00Z Package version: `20261109000000.0.0`. Publication status and artifacts are diff --git a/cmd/bulk-delete-invalid-name_test.go b/cmd/bulk-delete-invalid-name_test.go new file mode 100644 index 000000000..c8fc4b091 --- /dev/null +++ b/cmd/bulk-delete-invalid-name_test.go @@ -0,0 +1,156 @@ +// Copyright (c) 2026 PGSTY +// SPDX-License-Identifier: AGPL-3.0-or-later + +package cmd + +import ( + "bytes" + "encoding/xml" + "net/http" + "net/http/httptest" + "slices" + "testing" + + "github.com/minio/minio/internal/auth" +) + +// A bulk delete joins each name to the request bucket on the drives, so a name +// with a ".." segment names a path in another bucket or in the meta bucket. +// Such a name is the error of its own entry and reaches no drive, and the +// other entries of the request are deleted as usual. This holds for the one +// bulk lock of a single node, for a pool that locks per set, and for more than +// one pool. +func TestPoolsBulkDeleteInvalidNames(t *testing.T) { + for _, topology := range []struct { + name string + new func(t *testing.T) (*erasureServerPools, string) + }{ + {"single node", func(t *testing.T) (*erasureServerPools, string) { return bulkDeletePools(t, 1) }}, + {"set locks", func(t *testing.T) (*erasureServerPools, string) { return ldPool(t, ldLocal) }}, + {"two pools", func(t *testing.T) (*erasureServerPools, string) { return bulkDeletePools(t, 2) }}, + } { + t.Run(topology.name, func(t *testing.T) { + z, bucket := topology.new(t) + ctx := t.Context() + other := "bulk-delete-other" + if err := z.MakeBucket(ctx, other, MakeBucketOptions{VersioningEnabled: true}); err != nil { + t.Fatal(err) + } + old := batchPutVersion(t, z, other, "kept", nil) + latest := batchPutVersion(t, z, other, "kept", nil) + batchPutVersion(t, z, other, "visible", nil) + metaKey := pathJoin(bucketMetaPrefix, other, bucketMetadataFile) + if !batchVersionExists(t, z, minioMetaBucket, metaKey, "") { + t.Fatal("the bucket metadata is not an object of the meta bucket") + } + for _, key := range []string{"own", "dir/"} { + if _, err := z.PutObject(ctx, bucket, key, mustGetPutObjReader(t, bytes.NewReader(nil), 0, "", ""), ObjectOptions{}); err != nil { + t.Fatal(err) + } + } + + names := []ObjectToDelete{ + {ObjectV: ObjectV{ObjectName: "../" + other + "/kept", VersionID: old.VersionID}}, // purge in another bucket + {ObjectV: ObjectV{ObjectName: "own"}}, + {ObjectV: ObjectV{ObjectName: "a/../../" + other + "/visible"}}, // marker in another bucket + {ObjectV: ObjectV{ObjectName: "../" + minioMetaBucket + "/" + metaKey}}, // marker on bucket metadata + {ObjectV: ObjectV{ObjectName: "dir/"}}, + {ObjectV: ObjectV{ObjectName: ""}}, + } + invalid := map[int]bool{0: true, 2: true, 3: true, 5: true} + // Without a version ID these options ask for a delete marker, the + // one write a bulk delete makes. + deleted, errs := z.DeleteObjects(ctx, bucket, slices.Clone(names), ObjectOptions{Versioned: true}) + for i, name := range names { + _, rejected := errs[i].(ObjectNameInvalid) + if rejected != invalid[i] || !invalid[i] && errs[i] != nil { + t.Errorf("%d %q: error = %v, rejected as invalid = %v, want %v", i, name.ObjectName, errs[i], rejected, invalid[i]) + } + if deleted[i].ObjectName != name.ObjectName || deleted[i].found == invalid[i] { + t.Errorf("%d %q: result = %+v", i, name.ObjectName, deleted[i]) + } + } + + for _, version := range []string{old.VersionID, latest.VersionID} { + if !batchVersionExists(t, z, other, "kept", version) { + t.Errorf("version %q of the object in the other bucket was removed", version) + } + } + if !batchVersionExists(t, z, other, "visible", "") { + t.Error("the object of the other bucket is hidden by a marker") + } + if !batchVersionExists(t, z, minioMetaBucket, metaKey, "") { + t.Error("the metadata of the other bucket is hidden by a marker") + } + for _, key := range []string{"own", "dir/"} { + if batchVersionExists(t, z, bucket, key, "") { + t.Errorf("%q of the request bucket was not deleted", key) + } + } + }) + } +} + +// The names of an S3 DeleteObjects request come from its body, which the path +// checks of the request URL do not see. A user allowed to delete in one bucket +// reaches nothing outside it through such a name: a version under COMPLIANCE +// retention in another bucket stays, and no delete marker lands on that object +// or on the stored metadata of its bucket. +func TestBulkDeleteObjectLockNameOutsideBucket(t *testing.T) { + defer DetectTestLeak(t)() + ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{ + t: t, endpoints: []string{"DeleteMultipleObjects"}, + makeBucketOptions: MakeBucketOptions{VersioningEnabled: true}, + objAPITest: func(obj ObjectLayer, instance, bucket string, router http.Handler, _ auth.Credentials, t *testing.T) { + ctx := t.Context() + user := newObjectAttributesAuthzUser(t, instance, bucket, `"s3:DeleteObject","s3:DeleteObjectVersion"`) + locked := getRandomBucketName() + if err := obj.MakeBucket(ctx, locked, MakeBucketOptions{VersioningEnabled: true, LockEnabled: true}); err != nil { + t.Fatal(err) + } + enableBucketObjectLock(t, locked) + worm := batchPutVersion(t, obj, locked, "secret", batchLockMeta("COMPLIANCE")) + batchPutVersion(t, obj, bucket, "own", nil) + metaKey := pathJoin(bucketMetaPrefix, locked, bucketMetadataFile) + + outside := []ObjectToDelete{ + {ObjectV: ObjectV{ObjectName: "../" + locked + "/secret", VersionID: worm.VersionID}}, + {ObjectV: ObjectV{ObjectName: "../" + locked + "/secret"}}, + {ObjectV: ObjectV{ObjectName: "../" + minioMetaBucket + "/" + metaKey}}, + } + body := encodeResponse(DeleteObjectsRequest{Objects: append(slices.Clone(outside), ObjectToDelete{ObjectV: ObjectV{ObjectName: "own"}})}) + req, err := newTestSignedRequestV4(http.MethodPost, getDeleteMultipleObjectsURL("", bucket), int64(len(body)), bytes.NewReader(body), + user.AccessKey, user.SecretKey, nil) + if err != nil { + t.Fatal(err) + } + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + var resp DeleteObjectsResponse + if rec.Code != http.StatusOK || xml.Unmarshal(rec.Body.Bytes(), &resp) != nil { + t.Fatalf("%s: status %d: %s", instance, rec.Code, rec.Body.String()) + } + + if len(resp.DeletedObjects) != 1 || resp.DeletedObjects[0].ObjectName != "own" || len(resp.Errors) != len(outside) { + t.Errorf("%s: want the three names outside the bucket refused and \"own\" deleted: %s", instance, rec.Body.String()) + } + for _, e := range resp.Errors { + if e.Code != errorCodes[ErrInvalidObjectName].Code { + t.Errorf("%s: %q: code %s, want %s", instance, e.Key, e.Code, errorCodes[ErrInvalidObjectName].Code) + } + } + if !batchVersionExists(t, obj, locked, "secret", worm.VersionID) { + t.Errorf("%s: the version under COMPLIANCE retention in the other bucket was removed", instance) + } + if !batchVersionExists(t, obj, locked, "secret", "") { + t.Errorf("%s: a delete marker hides the object of the other bucket", instance) + } + if !batchVersionExists(t, obj, minioMetaBucket, metaKey, "") { + t.Errorf("%s: a delete marker hides the stored metadata of the other bucket", instance) + } + if batchVersionExists(t, obj, bucket, "own", "") { + t.Errorf("%s: the object of the request bucket was not deleted", instance) + } + }, + }) +} diff --git a/cmd/erasure-server-pool.go b/cmd/erasure-server-pool.go index 3e06066bd..907a88b18 100644 --- a/cmd/erasure-server-pool.go +++ b/cmd/erasure-server-pool.go @@ -26,6 +26,7 @@ import ( "math/rand" "net/http" "path" + "slices" "sort" "strconv" "strings" @@ -1543,6 +1544,27 @@ func (z *erasureServerPools) DeleteObjects(ctx context.Context, bucket string, o objSets.Add(objects[i].ObjectName) } + // A name that fails the check keeps its error and goes no further. Joined + // to the bucket it can name a path in another bucket or in the meta + // bucket, so it is not locked, looked up or sent to a drive. The other + // names are deleted as a request of their own: everything below sees only + // names that passed. + if slices.ContainsFunc(derrs, func(err error) bool { return err != nil }) { + valid, at := make([]ObjectToDelete, 0, len(objects)), make([]int, 0, len(objects)) + for i, obj := range objects { + if derrs[i] == nil { + valid, at = append(valid, obj), append(at, i) + } + } + if len(valid) > 0 { + deleted, errs := z.DeleteObjects(ctx, bucket, valid, opts) + for j, i := range at { + dobjects[i], derrs[i] = deleted[j], errs[j] + } + } + return dobjects, derrs + } + // A single pool locks each name in the set its writers lock it in, one // set at a time. A single node keeps one lock table for all sets, so it // keeps the one bulk lock below: its local lock takes the names one by From 3269d2214e94b540a8e61cf4fad59be756476508 Mon Sep 17 00:00:00 2001 From: Feng Ruohang Date: Tue, 10 Nov 2026 11:00:05 +0800 Subject: [PATCH 2/5] docs: align release, TLS, healthcheck, and contributor docs with 20261109 Signed-off-by: Feng Ruohang --- CHANGELOG.md | 15 ++++++----- CONTRIBUTING.md | 7 ++++-- CONTRIBUTORS.md | 40 ++++++++++++++++-------------- PULL_REQUESTS_ETIQUETTE.md | 3 ++- README.md | 10 ++++---- README_ZH.md | 12 ++++----- SECURITY.md | 2 +- docs/metrics/healthcheck/README.md | 8 +++--- 8 files changed, 53 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 44d6081b3..20d7d0aaf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,11 +15,13 @@ ## RELEASE.2026-11-09T00-00-00Z -Package version: `20261109000000.0.0`. Publication status and artifacts are -listed in [GitHub Releases](https://github.com/pgsty/silo/releases). This -section covers the changes after the published 20261026 source `c3ad836aed0d`. -Most fixes come from reports and patches by -[@botdeniska](https://github.com/botdeniska); see [CONTRIBUTORS.md](CONTRIBUTORS.md). +Package version: `20261109000000.0.0`; published from source `d998292387cc`. +Artifacts are listed in the [GitHub release](https://github.com/pgsty/silo/releases/tag/RELEASE.2026-11-09T00-00-00Z). +The coordinated [English release notes](https://silo.pgsty.com/blog/release/silo-20261109/) +and [Chinese release notes](https://silo.pgsty.com/zh/blog/release/silo-20261109/) +cover all components, upgrade guidance, and validation scope since the +published 20261026 source `c3ad836aed0d`. Most fixes come from reports and +patches by [@botdeniska](https://github.com/botdeniska); see [CONTRIBUTORS.md](CONTRIBUTORS.md). Lifecycle, multi-pool and tiering: @@ -34,7 +36,8 @@ Lifecycle, multi-pool and tiering: `retainVersions: 0`) on the expired key instead of the keys below it; a key with a tiered or Object Lock protected version is refused ([#297](https://github.com/pgsty/silo/issues/297)). -- Stop taking the free version of a deleted tiered version for a version +- Exclude the internal free-version marker of a deleted tiered version from + visible version history and lifecycle decisions ([#263](https://github.com/pgsty/silo/issues/263)), apply rules whose only action is `DelMarkerExpiration` ([#268](https://github.com/pgsty/silo/issues/268)), and report and account only the removals that happened diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 34a894632..e31110f15 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -165,8 +165,11 @@ license grant to SILO or upstream MinIO maintainers is required. ### How does Silo manage dependencies? -Silo uses Go modules. Preserve the compatibility module and import paths in -`go.mod`; downstream forks are selected with explicit `replace` directives. +Silo uses Go modules. Shared packages in `github.com/pgsty/silo-pkg/v3` are +required and imported directly under their own module path, upstream +`github.com/minio/minio-go/v7` is pinned to a verified commit, and Console +(`github.com/minio/console`) and MC (`github.com/minio/mc`) are selected with +explicit `replace` directives in `go.mod`. - Run `go get foo/bar` in the source folder to add the dependency to `go.mod` file. diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index bfcd23b26..cd2763a9b 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -2,7 +2,7 @@ -As of **2026-11-09**, **76 community contributors** are credited in SILO and related projects. The roll includes maintainers and every human issue / PR author, along with security reporters who have agreed to be acknowledged. +As of **2026-11-10**, **76 community contributors** are credited in SILO and related projects. The roll includes maintainers and every human issue / PR author, along with security reporters who have agreed to be acknowledged. Opening an issue or PR counts, whether open, closed, draft, or unmerged. Each account appears once. Merged PR authors come first, followed by other PR authors and reporters. Within each group, significant fixes, adopted proposals, and reports that led to fixes take priority, followed by first participation. Gold rings highlight significant contributions. @@ -49,7 +49,7 @@ When a proposal was incorporated through a later repair, its adoption is credite @nik0xFF @szopi00 @GhaziTriki -@haf +@haf @sun1024 @mosesdd @Xavier-777 @@ -91,7 +91,7 @@ When a proposal was incorporated through a later repair, its adoption is credite | Contributor | Contribution | Record | | :-- | :-- | :-- | -| [@Vonng](https://github.com/Vonng) | Maintains SILO, Console, mcli, shared packages, releases, and documentation | [pgsty/silo: Merged PRs (93)](https://github.com/pgsty/silo/issues?q=author%3AVonng+is%3Apr+is%3Amerged)
PRs closed without merging: [pgsty/silo#155](https://github.com/pgsty/silo/pull/155), [pgsty/silo#195](https://github.com/pgsty/silo/pull/195)
[pgsty/silo: Closed issues (99)](https://github.com/pgsty/silo/issues?q=author%3AVonng+is%3Aissue+is%3Aclosed)
Open issues: [pgsty/silo#217](https://github.com/pgsty/silo/issues/217)
[pgsty/silo-console: Merged PRs (42)](https://github.com/pgsty/silo-console/issues?q=author%3AVonng+is%3Apr+is%3Amerged)
[pgsty/silo-console: Closed issues (39)](https://github.com/pgsty/silo-console/issues?q=author%3AVonng+is%3Aissue+is%3Aclosed)
[pgsty/mc: Merged PRs (30)](https://github.com/pgsty/mc/issues?q=author%3AVonng+is%3Apr+is%3Amerged)
[pgsty/mc: Closed issues (42)](https://github.com/pgsty/mc/issues?q=author%3AVonng+is%3Aissue+is%3Aclosed)
Merged PRs: [pgsty/silo-pkg#1](https://github.com/pgsty/silo-pkg/pull/1), [pgsty/silo-pkg#2](https://github.com/pgsty/silo-pkg/pull/2), [pgsty/silo-pkg#3](https://github.com/pgsty/silo-pkg/pull/3), [pgsty/silo-pkg#4](https://github.com/pgsty/silo-pkg/pull/4), [pgsty/silo-pkg#5](https://github.com/pgsty/silo-pkg/pull/5), [pgsty/silo-pkg#6](https://github.com/pgsty/silo-pkg/pull/6), [pgsty/silo-pkg#7](https://github.com/pgsty/silo-pkg/pull/7), [pgsty/silo-pkg#8](https://github.com/pgsty/silo-pkg/pull/8), [pgsty/silo-pkg#9](https://github.com/pgsty/silo-pkg/pull/9), [pgsty/silo-pkg#10](https://github.com/pgsty/silo-pkg/pull/10), [pgsty/silo-pkg#12](https://github.com/pgsty/silo-pkg/pull/12), [pgsty/silo-pkg#14](https://github.com/pgsty/silo-pkg/pull/14)
Closed issues: [pgsty/silo-pkg#13](https://github.com/pgsty/silo-pkg/issues/13)
Merged PRs: [pgsty/kes#1](https://github.com/pgsty/kes/pull/1)
[pgsty/silo.pgsty.com: Merged PRs (48)](https://github.com/pgsty/silo.pgsty.com/issues?q=author%3AVonng+is%3Apr+is%3Amerged)
PRs closed without merging: [pgsty/silo.pgsty.com#36](https://github.com/pgsty/silo.pgsty.com/pull/36) | +| [@Vonng](https://github.com/Vonng) | Maintains SILO, Console, mcli, shared packages, releases, and documentation | [pgsty/silo: Merged PRs (95)](https://github.com/pgsty/silo/issues?q=author%3AVonng+is%3Apr+is%3Amerged)
Open PRs: [pgsty/silo#329](https://github.com/pgsty/silo/pull/329)
PRs closed without merging: [pgsty/silo#155](https://github.com/pgsty/silo/pull/155), [pgsty/silo#195](https://github.com/pgsty/silo/pull/195)
[pgsty/silo: Closed issues (99)](https://github.com/pgsty/silo/issues?q=author%3AVonng+is%3Aissue+is%3Aclosed)
Open issues: [pgsty/silo#217](https://github.com/pgsty/silo/issues/217)
[pgsty/silo-console: Merged PRs (44)](https://github.com/pgsty/silo-console/issues?q=author%3AVonng+is%3Apr+is%3Amerged)
[pgsty/silo-console: Closed issues (39)](https://github.com/pgsty/silo-console/issues?q=author%3AVonng+is%3Aissue+is%3Aclosed)
[pgsty/mc: Merged PRs (30)](https://github.com/pgsty/mc/issues?q=author%3AVonng+is%3Apr+is%3Amerged)
[pgsty/mc: Closed issues (42)](https://github.com/pgsty/mc/issues?q=author%3AVonng+is%3Aissue+is%3Aclosed)
Merged PRs: [pgsty/silo-pkg#1](https://github.com/pgsty/silo-pkg/pull/1), [pgsty/silo-pkg#2](https://github.com/pgsty/silo-pkg/pull/2), [pgsty/silo-pkg#3](https://github.com/pgsty/silo-pkg/pull/3), [pgsty/silo-pkg#4](https://github.com/pgsty/silo-pkg/pull/4), [pgsty/silo-pkg#5](https://github.com/pgsty/silo-pkg/pull/5), [pgsty/silo-pkg#6](https://github.com/pgsty/silo-pkg/pull/6), [pgsty/silo-pkg#7](https://github.com/pgsty/silo-pkg/pull/7), [pgsty/silo-pkg#8](https://github.com/pgsty/silo-pkg/pull/8), [pgsty/silo-pkg#9](https://github.com/pgsty/silo-pkg/pull/9), [pgsty/silo-pkg#10](https://github.com/pgsty/silo-pkg/pull/10), [pgsty/silo-pkg#12](https://github.com/pgsty/silo-pkg/pull/12), [pgsty/silo-pkg#14](https://github.com/pgsty/silo-pkg/pull/14)
Closed issues: [pgsty/silo-pkg#13](https://github.com/pgsty/silo-pkg/issues/13)
Merged PRs: [pgsty/kes#1](https://github.com/pgsty/kes/pull/1)
[pgsty/silo.pgsty.com: Merged PRs (52)](https://github.com/pgsty/silo.pgsty.com/issues?q=author%3AVonng+is%3Apr+is%3Amerged)
PRs closed without merging: [pgsty/silo.pgsty.com#36](https://github.com/pgsty/silo.pgsty.com/pull/36) | | [@ZouhairCharef](https://github.com/ZouhairCharef) | Patched CVE-2026-34986 in go-jose | Merged PRs: [pgsty/silo#18](https://github.com/pgsty/silo/pull/18) | | [@mfredenhagen](https://github.com/mfredenhagen) | Patched CVE-2026-39883 in OpenTelemetry | Merged PRs: [pgsty/silo#19](https://github.com/pgsty/silo/pull/19) | | [@cbornet](https://github.com/cbornet) | Reported checksum and missing-bucket defects and aligned S3 checksum error messages | Merged PRs: [pgsty/silo#225](https://github.com/pgsty/silo/pull/225)
Closed issues: [pgsty/silo#31](https://github.com/pgsty/silo/issues/31), [pgsty/silo#32](https://github.com/pgsty/silo/issues/32), [pgsty/silo#107](https://github.com/pgsty/silo/issues/107), [pgsty/silo#226](https://github.com/pgsty/silo/issues/226) | @@ -99,7 +99,7 @@ When a proposal was incorporated through a later repair, its adoption is credite | [@ycjlin](https://github.com/ycjlin) | Fixed missing-bucket ListObjects semantics | Merged PRs: [pgsty/silo#37](https://github.com/pgsty/silo/pull/37) | | [@waterkip](https://github.com/waterkip) | Repointed documentation links to the SILO portal | Merged PRs: [pgsty/silo#41](https://github.com/pgsty/silo/pull/41) | | [@Dansyuqri](https://github.com/Dansyuqri) | Added ChecksumType to multipart completion responses | Merged PRs: [pgsty/silo#57](https://github.com/pgsty/silo/pull/57) | -| [@mrjavadseydi](https://github.com/mrjavadseydi) | Fixed bucket quota metrics and mcli alias regions; contributed access-frequency ILM and S3-compatible multipart listing proposals | Merged PRs: [pgsty/silo#60](https://github.com/pgsty/silo/pull/60), [pgsty/silo#132](https://github.com/pgsty/silo/pull/132), [pgsty/silo#198](https://github.com/pgsty/silo/pull/198), [pgsty/silo#221](https://github.com/pgsty/silo/pull/221)
Open PRs: [pgsty/silo#249](https://github.com/pgsty/silo/pull/249)
PRs closed without merging: [pgsty/silo#215](https://github.com/pgsty/silo/pull/215)
Merged PRs: [pgsty/mc#44](https://github.com/pgsty/mc/pull/44)
The access-frequency feature in [#60](https://github.com/pgsty/silo/pull/60) was merged and later removed. The multipart-listing contribution in [#198](https://github.com/pgsty/silo/pull/198) was merged after follow-up durability and cancellation repairs; it shipped in Server 20260916 with legacy as the default and strict as an opt-in. | +| [@mrjavadseydi](https://github.com/mrjavadseydi) | Fixed bucket quota metrics and mcli alias regions; contributed access-frequency ILM and S3-compatible multipart listing proposals | Merged PRs: [pgsty/silo#60](https://github.com/pgsty/silo/pull/60), [pgsty/silo#132](https://github.com/pgsty/silo/pull/132), [pgsty/silo#198](https://github.com/pgsty/silo/pull/198), [pgsty/silo#221](https://github.com/pgsty/silo/pull/221)
Open PRs: [pgsty/silo#249](https://github.com/pgsty/silo/pull/249)
PRs closed without merging: [pgsty/silo#215](https://github.com/pgsty/silo/pull/215)
Merged PRs: [pgsty/mc#44](https://github.com/pgsty/mc/pull/44)
The access-frequency feature in [#60](https://github.com/pgsty/silo/pull/60) was merged and later removed. The multipart-listing contribution in [#198](https://github.com/pgsty/silo/pull/198) was merged after follow-up durability and cancellation repairs; it shipped in Server 20260916 with legacy as the default and strict as an opt-in, and the strict opt-in was subsequently retired in Server 20261018. | | [@h5vx](https://github.com/h5vx) | Implemented per-bucket CORS configuration and enforcement | Merged PRs: [pgsty/silo#71](https://github.com/pgsty/silo/pull/71) | | [@nikitapogromsky](https://github.com/nikitapogromsky) | Fixed duplicate logger targets that broke Metrics V3 collection | Merged PRs: [pgsty/silo#151](https://github.com/pgsty/silo/pull/151)
Closed issues: [pgsty/silo#150](https://github.com/pgsty/silo/issues/150) | | [@julienlau](https://github.com/julienlau) | Fixed missing-commit-metadata error handling and stale metadata reuse; contributed replication reports and proposals | Merged PRs: [pgsty/silo#232](https://github.com/pgsty/silo/pull/232), [pgsty/silo#233](https://github.com/pgsty/silo/pull/233)
Open PRs: [pgsty/silo#240](https://github.com/pgsty/silo/pull/240), [pgsty/silo#241](https://github.com/pgsty/silo/pull/241)
PRs closed without merging: [pgsty/silo#184](https://github.com/pgsty/silo/pull/184)
Closed issues: [pgsty/silo#152](https://github.com/pgsty/silo/issues/152), [pgsty/silo#153](https://github.com/pgsty/silo/issues/153)
The original PR was not merged directly. Its purge-state and recovery analysis, proposed fix, tests, and documentation helped shape [#196](https://github.com/pgsty/silo/pull/196); see the [maintainer acknowledgement](https://github.com/pgsty/silo/pull/184#issuecomment-5689295222). | @@ -110,7 +110,7 @@ When a proposal was incorporated through a later repair, its adoption is credite | [@botdeniska](https://github.com/botdeniska) | Contributed multi-pool migration, deletion and lifecycle safety, conditional multipart checks, native restore reports and verification, LDAP login and storage cleanup fixes, replication checksum repairs, and faster policy evaluation | [pgsty/silo: Merged PRs (13)](https://github.com/pgsty/silo/issues?q=author%3Abotdeniska+is%3Apr+is%3Amerged)
Open PRs: [pgsty/silo#294](https://github.com/pgsty/silo/pull/294)
PRs closed without merging: [pgsty/silo#245](https://github.com/pgsty/silo/pull/245), [pgsty/silo#248](https://github.com/pgsty/silo/pull/248), [pgsty/silo#262](https://github.com/pgsty/silo/pull/262), [pgsty/silo#290](https://github.com/pgsty/silo/pull/290)
[pgsty/silo: Closed issues (15)](https://github.com/pgsty/silo/issues?q=author%3Abotdeniska+is%3Aissue+is%3Aclosed)
Open issues: [pgsty/silo#293](https://github.com/pgsty/silo/issues/293)
Merged PRs: [pgsty/silo-pkg#11](https://github.com/pgsty/silo-pkg/pull/11)
The migration repair from [#248](https://github.com/pgsty/silo/pull/248) was incorporated in [60c7179dd](https://github.com/pgsty/silo/commit/60c7179ddbfcbd41a1fe67707230f5b360a43020), retaining the original authorship and DCO. The mixed-EC listing proposal in [#262](https://github.com/pgsty/silo/pull/262) was superseded by [#267](https://github.com/pgsty/silo/pull/267). The author closed [#290](https://github.com/pgsty/silo/pull/290) after verifying the native-restore rework covered the reported defects. | | [@910patin](https://github.com/910patin) | Fixed long-DN LDAP login while preserving IAM revocation-record errors | Merged PRs: [pgsty/silo#250](https://github.com/pgsty/silo/pull/250) | | [@cyberbret](https://github.com/cyberbret) | Contributed on-demand folder and bucket usage scans and the optional persistent usage worker | Merged PRs: [pgsty/silo-console#70](https://github.com/pgsty/silo-console/pull/70)
PR [#70](https://github.com/pgsty/silo-console/pull/70) was integrated through [#79](https://github.com/pgsty/silo-console/pull/79), retaining the original commits and authorship. Usage scans and the optional worker shipped in [Console v2.6.0](https://github.com/pgsty/silo-console/releases/tag/v2.6.0). | -| [@EduardoAndradeMachado](https://github.com/EduardoAndradeMachado) | Contributed Brazilian Portuguese translations to Console and privately reported a security issue | Merged PRs: [pgsty/silo-console#83](https://github.com/pgsty/silo-console/pull/83)
Private security reporting is acknowledged with the reporter's agreement. | +| [@EduardoAndradeMachado](https://github.com/EduardoAndradeMachado) | Contributed Brazilian Portuguese translations to Console and privately reported a security issue | Merged PRs: [pgsty/silo-console#83](https://github.com/pgsty/silo-console/pull/83)
PR [#83](https://github.com/pgsty/silo-console/pull/83) was merged with its original authored commits; the later ten-language implementation uses shared static dictionaries. Private security reporting is acknowledged with the reporter's agreement. | ## Other pull-request authors @@ -141,7 +141,7 @@ When a proposal was incorporated through a later repair, its adoption is credite | [@nik0xFF](https://github.com/nik0xFF) | Reported a security issue privately by email | Dominik Klug is credited under his full name with his permission. | | [@szopi00](https://github.com/szopi00) | Reported the Object Lock GOVERNANCE retention authorization bypass | The report led to the Object Lock repairs in [#234](https://github.com/pgsty/silo/pull/234) and [#235](https://github.com/pgsty/silo/pull/235). | | [@GhaziTriki](https://github.com/GhaziTriki) | Investigated policy-query and Console slowdown and identified overlapping client mirror jobs | Closed issues: [pgsty/silo#251](https://github.com/pgsty/silo/issues/251)
Closed after the [reporter follow-up](https://github.com/pgsty/silo/issues/251#issuecomment-6417722845) traced the immediate cause to overlapping client mirror jobs; this incident did not establish a SILO version regression. | -| [@haf](https://github.com/haf) | Reported audit webhook delivery stopping on peer nodes after configuration reload | Closed issues: [pgsty/silo#259](https://github.com/pgsty/silo/issues/259)
The report led to webhook target lifetime and queue recovery repairs in [0fa4a8c50](https://github.com/pgsty/silo/commit/0fa4a8c503941d94c60045dc7a4dbff4d93fca34) and [f3e84d54a](https://github.com/pgsty/silo/commit/f3e84d54a9c869d8baf53bab02b8bc2c81bd1dfb). | +| [@haf](https://github.com/haf) | Reported audit and logger webhook delivery stopping on peer nodes after configuration reload | Closed issues: [pgsty/silo#259](https://github.com/pgsty/silo/issues/259)
The report led to webhook target lifetime and queue recovery repairs in [0fa4a8c50](https://github.com/pgsty/silo/commit/0fa4a8c503941d94c60045dc7a4dbff4d93fca34) and [f3e84d54a](https://github.com/pgsty/silo/commit/f3e84d54a9c869d8baf53bab02b8bc2c81bd1dfb). | | [@sun1024](https://github.com/sun1024) | Privately reported a security issue in SILO | Acknowledged with the reporter's agreement; private report details are not included here. | | [@mosesdd](https://github.com/mosesdd) | Requested a maintained Helm chart | Closed issues: [pgsty/silo#1](https://github.com/pgsty/silo/issues/1) | | [@Xavier-777](https://github.com/Xavier-777) | Reported Console lifecycle management and file preview gaps | Closed issues: [pgsty/silo#2](https://github.com/pgsty/silo/issues/2), [pgsty/silo#17](https://github.com/pgsty/silo/issues/17) | @@ -176,7 +176,7 @@ When a proposal was incorporated through a later repair, its adoption is credite | [@devTeam-AiReg](https://github.com/devTeam-AiReg) | Asked about availability of the release containing IAM security fixes | Closed issues: [pgsty/silo#247](https://github.com/pgsty/silo/issues/247) | | [@azataiot](https://github.com/azataiot) | Reported and analyzed high-latency Console health-report truncation during WebSocket close | Closed issues: [pgsty/silo-console#63](https://github.com/pgsty/silo-console/issues/63) | | [@siyahkut](https://github.com/siyahkut) | Requested Turkish language support | Open issues: [pgsty/silo#277](https://github.com/pgsty/silo/issues/277) | -| [@seumasdunlop](https://github.com/seumasdunlop) | Reported the outdated default server image tag in the Helm chart | Open issues: [pgsty/silo#296](https://github.com/pgsty/silo/issues/296) | +| [@seumasdunlop](https://github.com/seumasdunlop) | Reported the outdated default server image tag in the Helm chart | Closed issues: [pgsty/silo#296](https://github.com/pgsty/silo/issues/296)
Resolved in Helm chart 7.0.4 ([47275042f](https://github.com/pgsty/silo/commit/47275042fda34066a97914bf97e2f80ef44c77ae)). | ## Audit scope @@ -184,22 +184,24 @@ Counts below come from every page of the GitHub issue / PR records, across all s | Repository | Issues | Pull requests | Reviewed | | :-- | --: | --: | :-- | -| [pgsty/silo](https://github.com/pgsty/silo) | 171 | 151 | 2026-11-09 | -| [pgsty/silo-console](https://github.com/pgsty/silo-console) | 42 | 44 | 2026-11-09 | -| [pgsty/mc](https://github.com/pgsty/mc) | 45 | 32 | 2026-11-09 | -| [pgsty/silo-pkg](https://github.com/pgsty/silo-pkg) | 1 | 13 | 2026-11-09 | -| [pgsty/silo-go](https://github.com/pgsty/silo-go) | 0 | 0 | 2026-11-09 | -| [pgsty/kes](https://github.com/pgsty/kes) | 0 | 1 | 2026-11-09 | -| [pgsty/silo.pgsty.com](https://github.com/pgsty/silo.pgsty.com) | 1 | 50 | 2026-11-09 | -| [pgsty/minio-docs](https://github.com/pgsty/minio-docs) | 0 | 0 | 2026-11-09 | -| **Total** | **260** | **291** | | +| [pgsty/silo](https://github.com/pgsty/silo) | 171 | 154 | 2026-11-10 | +| [pgsty/silo-console](https://github.com/pgsty/silo-console) | 42 | 46 | 2026-11-10 | +| [pgsty/mc](https://github.com/pgsty/mc) | 45 | 32 | 2026-11-10 | +| [pgsty/silo-pkg](https://github.com/pgsty/silo-pkg) | 1 | 13 | 2026-11-10 | +| [pgsty/silo-go](https://github.com/pgsty/silo-go) | 0 | 0 | 2026-11-10 | +| [pgsty/kes](https://github.com/pgsty/kes) | 0 | 1 | 2026-11-10 | +| [pgsty/silo.pgsty.com](https://github.com/pgsty/silo.pgsty.com) | 1 | 54 | 2026-11-10 | +| [pgsty/minio-docs](https://github.com/pgsty/minio-docs) | 0 | 0 | 2026-11-10 | +| **Total** | **260** | **300** | | ## Maintainer record
@Vonng — complete issue / PR record -Merged PRs: [pgsty/silo#44](https://github.com/pgsty/silo/pull/44), [pgsty/silo#45](https://github.com/pgsty/silo/pull/45), [pgsty/silo#56](https://github.com/pgsty/silo/pull/56), [pgsty/silo#66](https://github.com/pgsty/silo/pull/66), [pgsty/silo#69](https://github.com/pgsty/silo/pull/69), [pgsty/silo#70](https://github.com/pgsty/silo/pull/70), [pgsty/silo#72](https://github.com/pgsty/silo/pull/72), [pgsty/silo#73](https://github.com/pgsty/silo/pull/73), [pgsty/silo#74](https://github.com/pgsty/silo/pull/74), [pgsty/silo#80](https://github.com/pgsty/silo/pull/80), [pgsty/silo#81](https://github.com/pgsty/silo/pull/81), [pgsty/silo#85](https://github.com/pgsty/silo/pull/85), [pgsty/silo#86](https://github.com/pgsty/silo/pull/86), [pgsty/silo#87](https://github.com/pgsty/silo/pull/87), [pgsty/silo#88](https://github.com/pgsty/silo/pull/88), [pgsty/silo#89](https://github.com/pgsty/silo/pull/89), [pgsty/silo#90](https://github.com/pgsty/silo/pull/90), [pgsty/silo#91](https://github.com/pgsty/silo/pull/91), [pgsty/silo#92](https://github.com/pgsty/silo/pull/92), [pgsty/silo#93](https://github.com/pgsty/silo/pull/93), [pgsty/silo#94](https://github.com/pgsty/silo/pull/94), [pgsty/silo#95](https://github.com/pgsty/silo/pull/95), [pgsty/silo#96](https://github.com/pgsty/silo/pull/96), [pgsty/silo#97](https://github.com/pgsty/silo/pull/97), [pgsty/silo#98](https://github.com/pgsty/silo/pull/98), [pgsty/silo#101](https://github.com/pgsty/silo/pull/101), [pgsty/silo#103](https://github.com/pgsty/silo/pull/103), [pgsty/silo#104](https://github.com/pgsty/silo/pull/104), [pgsty/silo#121](https://github.com/pgsty/silo/pull/121), [pgsty/silo#122](https://github.com/pgsty/silo/pull/122), [pgsty/silo#123](https://github.com/pgsty/silo/pull/123), [pgsty/silo#124](https://github.com/pgsty/silo/pull/124), [pgsty/silo#126](https://github.com/pgsty/silo/pull/126), [pgsty/silo#127](https://github.com/pgsty/silo/pull/127), [pgsty/silo#128](https://github.com/pgsty/silo/pull/128), [pgsty/silo#129](https://github.com/pgsty/silo/pull/129), [pgsty/silo#130](https://github.com/pgsty/silo/pull/130), [pgsty/silo#131](https://github.com/pgsty/silo/pull/131), [pgsty/silo#134](https://github.com/pgsty/silo/pull/134), [pgsty/silo#135](https://github.com/pgsty/silo/pull/135), [pgsty/silo#138](https://github.com/pgsty/silo/pull/138), [pgsty/silo#140](https://github.com/pgsty/silo/pull/140), [pgsty/silo#142](https://github.com/pgsty/silo/pull/142), [pgsty/silo#143](https://github.com/pgsty/silo/pull/143), [pgsty/silo#145](https://github.com/pgsty/silo/pull/145), [pgsty/silo#146](https://github.com/pgsty/silo/pull/146), [pgsty/silo#149](https://github.com/pgsty/silo/pull/149), [pgsty/silo#156](https://github.com/pgsty/silo/pull/156), [pgsty/silo#157](https://github.com/pgsty/silo/pull/157), [pgsty/silo#159](https://github.com/pgsty/silo/pull/159), [pgsty/silo#161](https://github.com/pgsty/silo/pull/161), [pgsty/silo#162](https://github.com/pgsty/silo/pull/162), [pgsty/silo#163](https://github.com/pgsty/silo/pull/163), [pgsty/silo#164](https://github.com/pgsty/silo/pull/164), [pgsty/silo#173](https://github.com/pgsty/silo/pull/173), [pgsty/silo#174](https://github.com/pgsty/silo/pull/174), [pgsty/silo#175](https://github.com/pgsty/silo/pull/175), [pgsty/silo#177](https://github.com/pgsty/silo/pull/177), [pgsty/silo#178](https://github.com/pgsty/silo/pull/178), [pgsty/silo#179](https://github.com/pgsty/silo/pull/179), [pgsty/silo#180](https://github.com/pgsty/silo/pull/180), [pgsty/silo#181](https://github.com/pgsty/silo/pull/181), [pgsty/silo#182](https://github.com/pgsty/silo/pull/182), [pgsty/silo#188](https://github.com/pgsty/silo/pull/188), [pgsty/silo#189](https://github.com/pgsty/silo/pull/189), [pgsty/silo#190](https://github.com/pgsty/silo/pull/190), [pgsty/silo#191](https://github.com/pgsty/silo/pull/191), [pgsty/silo#192](https://github.com/pgsty/silo/pull/192), [pgsty/silo#193](https://github.com/pgsty/silo/pull/193), [pgsty/silo#194](https://github.com/pgsty/silo/pull/194), [pgsty/silo#196](https://github.com/pgsty/silo/pull/196), [pgsty/silo#205](https://github.com/pgsty/silo/pull/205), [pgsty/silo#206](https://github.com/pgsty/silo/pull/206), [pgsty/silo#207](https://github.com/pgsty/silo/pull/207), [pgsty/silo#208](https://github.com/pgsty/silo/pull/208), [pgsty/silo#209](https://github.com/pgsty/silo/pull/209), [pgsty/silo#211](https://github.com/pgsty/silo/pull/211), [pgsty/silo#212](https://github.com/pgsty/silo/pull/212), [pgsty/silo#213](https://github.com/pgsty/silo/pull/213), [pgsty/silo#214](https://github.com/pgsty/silo/pull/214), [pgsty/silo#216](https://github.com/pgsty/silo/pull/216), [pgsty/silo#234](https://github.com/pgsty/silo/pull/234), [pgsty/silo#235](https://github.com/pgsty/silo/pull/235), [pgsty/silo#237](https://github.com/pgsty/silo/pull/237), [pgsty/silo#238](https://github.com/pgsty/silo/pull/238), [pgsty/silo#239](https://github.com/pgsty/silo/pull/239), [pgsty/silo#242](https://github.com/pgsty/silo/pull/242), [pgsty/silo#243](https://github.com/pgsty/silo/pull/243), [pgsty/silo#244](https://github.com/pgsty/silo/pull/244), [pgsty/silo#258](https://github.com/pgsty/silo/pull/258), [pgsty/silo#267](https://github.com/pgsty/silo/pull/267), [pgsty/silo#276](https://github.com/pgsty/silo/pull/276), [pgsty/silo#295](https://github.com/pgsty/silo/pull/295) +Merged PRs: [pgsty/silo#44](https://github.com/pgsty/silo/pull/44), [pgsty/silo#45](https://github.com/pgsty/silo/pull/45), [pgsty/silo#56](https://github.com/pgsty/silo/pull/56), [pgsty/silo#66](https://github.com/pgsty/silo/pull/66), [pgsty/silo#69](https://github.com/pgsty/silo/pull/69), [pgsty/silo#70](https://github.com/pgsty/silo/pull/70), [pgsty/silo#72](https://github.com/pgsty/silo/pull/72), [pgsty/silo#73](https://github.com/pgsty/silo/pull/73), [pgsty/silo#74](https://github.com/pgsty/silo/pull/74), [pgsty/silo#80](https://github.com/pgsty/silo/pull/80), [pgsty/silo#81](https://github.com/pgsty/silo/pull/81), [pgsty/silo#85](https://github.com/pgsty/silo/pull/85), [pgsty/silo#86](https://github.com/pgsty/silo/pull/86), [pgsty/silo#87](https://github.com/pgsty/silo/pull/87), [pgsty/silo#88](https://github.com/pgsty/silo/pull/88), [pgsty/silo#89](https://github.com/pgsty/silo/pull/89), [pgsty/silo#90](https://github.com/pgsty/silo/pull/90), [pgsty/silo#91](https://github.com/pgsty/silo/pull/91), [pgsty/silo#92](https://github.com/pgsty/silo/pull/92), [pgsty/silo#93](https://github.com/pgsty/silo/pull/93), [pgsty/silo#94](https://github.com/pgsty/silo/pull/94), [pgsty/silo#95](https://github.com/pgsty/silo/pull/95), [pgsty/silo#96](https://github.com/pgsty/silo/pull/96), [pgsty/silo#97](https://github.com/pgsty/silo/pull/97), [pgsty/silo#98](https://github.com/pgsty/silo/pull/98), [pgsty/silo#101](https://github.com/pgsty/silo/pull/101), [pgsty/silo#103](https://github.com/pgsty/silo/pull/103), [pgsty/silo#104](https://github.com/pgsty/silo/pull/104), [pgsty/silo#121](https://github.com/pgsty/silo/pull/121), [pgsty/silo#122](https://github.com/pgsty/silo/pull/122), [pgsty/silo#123](https://github.com/pgsty/silo/pull/123), [pgsty/silo#124](https://github.com/pgsty/silo/pull/124), [pgsty/silo#126](https://github.com/pgsty/silo/pull/126), [pgsty/silo#127](https://github.com/pgsty/silo/pull/127), [pgsty/silo#128](https://github.com/pgsty/silo/pull/128), [pgsty/silo#129](https://github.com/pgsty/silo/pull/129), [pgsty/silo#130](https://github.com/pgsty/silo/pull/130), [pgsty/silo#131](https://github.com/pgsty/silo/pull/131), [pgsty/silo#134](https://github.com/pgsty/silo/pull/134), [pgsty/silo#135](https://github.com/pgsty/silo/pull/135), [pgsty/silo#138](https://github.com/pgsty/silo/pull/138), [pgsty/silo#140](https://github.com/pgsty/silo/pull/140), [pgsty/silo#142](https://github.com/pgsty/silo/pull/142), [pgsty/silo#143](https://github.com/pgsty/silo/pull/143), [pgsty/silo#145](https://github.com/pgsty/silo/pull/145), [pgsty/silo#146](https://github.com/pgsty/silo/pull/146), [pgsty/silo#149](https://github.com/pgsty/silo/pull/149), [pgsty/silo#156](https://github.com/pgsty/silo/pull/156), [pgsty/silo#157](https://github.com/pgsty/silo/pull/157), [pgsty/silo#159](https://github.com/pgsty/silo/pull/159), [pgsty/silo#161](https://github.com/pgsty/silo/pull/161), [pgsty/silo#162](https://github.com/pgsty/silo/pull/162), [pgsty/silo#163](https://github.com/pgsty/silo/pull/163), [pgsty/silo#164](https://github.com/pgsty/silo/pull/164), [pgsty/silo#173](https://github.com/pgsty/silo/pull/173), [pgsty/silo#174](https://github.com/pgsty/silo/pull/174), [pgsty/silo#175](https://github.com/pgsty/silo/pull/175), [pgsty/silo#177](https://github.com/pgsty/silo/pull/177), [pgsty/silo#178](https://github.com/pgsty/silo/pull/178), [pgsty/silo#179](https://github.com/pgsty/silo/pull/179), [pgsty/silo#180](https://github.com/pgsty/silo/pull/180), [pgsty/silo#181](https://github.com/pgsty/silo/pull/181), [pgsty/silo#182](https://github.com/pgsty/silo/pull/182), [pgsty/silo#188](https://github.com/pgsty/silo/pull/188), [pgsty/silo#189](https://github.com/pgsty/silo/pull/189), [pgsty/silo#190](https://github.com/pgsty/silo/pull/190), [pgsty/silo#191](https://github.com/pgsty/silo/pull/191), [pgsty/silo#192](https://github.com/pgsty/silo/pull/192), [pgsty/silo#193](https://github.com/pgsty/silo/pull/193), [pgsty/silo#194](https://github.com/pgsty/silo/pull/194), [pgsty/silo#196](https://github.com/pgsty/silo/pull/196), [pgsty/silo#205](https://github.com/pgsty/silo/pull/205), [pgsty/silo#206](https://github.com/pgsty/silo/pull/206), [pgsty/silo#207](https://github.com/pgsty/silo/pull/207), [pgsty/silo#208](https://github.com/pgsty/silo/pull/208), [pgsty/silo#209](https://github.com/pgsty/silo/pull/209), [pgsty/silo#211](https://github.com/pgsty/silo/pull/211), [pgsty/silo#212](https://github.com/pgsty/silo/pull/212), [pgsty/silo#213](https://github.com/pgsty/silo/pull/213), [pgsty/silo#214](https://github.com/pgsty/silo/pull/214), [pgsty/silo#216](https://github.com/pgsty/silo/pull/216), [pgsty/silo#234](https://github.com/pgsty/silo/pull/234), [pgsty/silo#235](https://github.com/pgsty/silo/pull/235), [pgsty/silo#237](https://github.com/pgsty/silo/pull/237), [pgsty/silo#238](https://github.com/pgsty/silo/pull/238), [pgsty/silo#239](https://github.com/pgsty/silo/pull/239), [pgsty/silo#242](https://github.com/pgsty/silo/pull/242), [pgsty/silo#243](https://github.com/pgsty/silo/pull/243), [pgsty/silo#244](https://github.com/pgsty/silo/pull/244), [pgsty/silo#258](https://github.com/pgsty/silo/pull/258), [pgsty/silo#267](https://github.com/pgsty/silo/pull/267), [pgsty/silo#276](https://github.com/pgsty/silo/pull/276), [pgsty/silo#295](https://github.com/pgsty/silo/pull/295), [pgsty/silo#328](https://github.com/pgsty/silo/pull/328), [pgsty/silo#330](https://github.com/pgsty/silo/pull/330) + +Open PRs: [pgsty/silo#329](https://github.com/pgsty/silo/pull/329) PRs closed without merging: [pgsty/silo#155](https://github.com/pgsty/silo/pull/155), [pgsty/silo#195](https://github.com/pgsty/silo/pull/195) @@ -207,7 +209,7 @@ Closed issues: [pgsty/silo#22](https://github.com/pgsty/silo/issues/22), [pgsty/ Open issues: [pgsty/silo#217](https://github.com/pgsty/silo/issues/217) -Merged PRs: [pgsty/silo-console#9](https://github.com/pgsty/silo-console/pull/9), [pgsty/silo-console#10](https://github.com/pgsty/silo-console/pull/10), [pgsty/silo-console#11](https://github.com/pgsty/silo-console/pull/11), [pgsty/silo-console#38](https://github.com/pgsty/silo-console/pull/38), [pgsty/silo-console#39](https://github.com/pgsty/silo-console/pull/39), [pgsty/silo-console#40](https://github.com/pgsty/silo-console/pull/40), [pgsty/silo-console#41](https://github.com/pgsty/silo-console/pull/41), [pgsty/silo-console#42](https://github.com/pgsty/silo-console/pull/42), [pgsty/silo-console#43](https://github.com/pgsty/silo-console/pull/43), [pgsty/silo-console#44](https://github.com/pgsty/silo-console/pull/44), [pgsty/silo-console#45](https://github.com/pgsty/silo-console/pull/45), [pgsty/silo-console#46](https://github.com/pgsty/silo-console/pull/46), [pgsty/silo-console#47](https://github.com/pgsty/silo-console/pull/47), [pgsty/silo-console#48](https://github.com/pgsty/silo-console/pull/48), [pgsty/silo-console#49](https://github.com/pgsty/silo-console/pull/49), [pgsty/silo-console#50](https://github.com/pgsty/silo-console/pull/50), [pgsty/silo-console#51](https://github.com/pgsty/silo-console/pull/51), [pgsty/silo-console#53](https://github.com/pgsty/silo-console/pull/53), [pgsty/silo-console#54](https://github.com/pgsty/silo-console/pull/54), [pgsty/silo-console#55](https://github.com/pgsty/silo-console/pull/55), [pgsty/silo-console#56](https://github.com/pgsty/silo-console/pull/56), [pgsty/silo-console#57](https://github.com/pgsty/silo-console/pull/57), [pgsty/silo-console#58](https://github.com/pgsty/silo-console/pull/58), [pgsty/silo-console#59](https://github.com/pgsty/silo-console/pull/59), [pgsty/silo-console#60](https://github.com/pgsty/silo-console/pull/60), [pgsty/silo-console#61](https://github.com/pgsty/silo-console/pull/61), [pgsty/silo-console#62](https://github.com/pgsty/silo-console/pull/62), [pgsty/silo-console#64](https://github.com/pgsty/silo-console/pull/64), [pgsty/silo-console#65](https://github.com/pgsty/silo-console/pull/65), [pgsty/silo-console#66](https://github.com/pgsty/silo-console/pull/66), [pgsty/silo-console#67](https://github.com/pgsty/silo-console/pull/67), [pgsty/silo-console#68](https://github.com/pgsty/silo-console/pull/68), [pgsty/silo-console#69](https://github.com/pgsty/silo-console/pull/69), [pgsty/silo-console#71](https://github.com/pgsty/silo-console/pull/71), [pgsty/silo-console#74](https://github.com/pgsty/silo-console/pull/74), [pgsty/silo-console#79](https://github.com/pgsty/silo-console/pull/79), [pgsty/silo-console#80](https://github.com/pgsty/silo-console/pull/80), [pgsty/silo-console#81](https://github.com/pgsty/silo-console/pull/81), [pgsty/silo-console#82](https://github.com/pgsty/silo-console/pull/82), [pgsty/silo-console#84](https://github.com/pgsty/silo-console/pull/84), [pgsty/silo-console#85](https://github.com/pgsty/silo-console/pull/85), [pgsty/silo-console#86](https://github.com/pgsty/silo-console/pull/86) +Merged PRs: [pgsty/silo-console#9](https://github.com/pgsty/silo-console/pull/9), [pgsty/silo-console#10](https://github.com/pgsty/silo-console/pull/10), [pgsty/silo-console#11](https://github.com/pgsty/silo-console/pull/11), [pgsty/silo-console#38](https://github.com/pgsty/silo-console/pull/38), [pgsty/silo-console#39](https://github.com/pgsty/silo-console/pull/39), [pgsty/silo-console#40](https://github.com/pgsty/silo-console/pull/40), [pgsty/silo-console#41](https://github.com/pgsty/silo-console/pull/41), [pgsty/silo-console#42](https://github.com/pgsty/silo-console/pull/42), [pgsty/silo-console#43](https://github.com/pgsty/silo-console/pull/43), [pgsty/silo-console#44](https://github.com/pgsty/silo-console/pull/44), [pgsty/silo-console#45](https://github.com/pgsty/silo-console/pull/45), [pgsty/silo-console#46](https://github.com/pgsty/silo-console/pull/46), [pgsty/silo-console#47](https://github.com/pgsty/silo-console/pull/47), [pgsty/silo-console#48](https://github.com/pgsty/silo-console/pull/48), [pgsty/silo-console#49](https://github.com/pgsty/silo-console/pull/49), [pgsty/silo-console#50](https://github.com/pgsty/silo-console/pull/50), [pgsty/silo-console#51](https://github.com/pgsty/silo-console/pull/51), [pgsty/silo-console#53](https://github.com/pgsty/silo-console/pull/53), [pgsty/silo-console#54](https://github.com/pgsty/silo-console/pull/54), [pgsty/silo-console#55](https://github.com/pgsty/silo-console/pull/55), [pgsty/silo-console#56](https://github.com/pgsty/silo-console/pull/56), [pgsty/silo-console#57](https://github.com/pgsty/silo-console/pull/57), [pgsty/silo-console#58](https://github.com/pgsty/silo-console/pull/58), [pgsty/silo-console#59](https://github.com/pgsty/silo-console/pull/59), [pgsty/silo-console#60](https://github.com/pgsty/silo-console/pull/60), [pgsty/silo-console#61](https://github.com/pgsty/silo-console/pull/61), [pgsty/silo-console#62](https://github.com/pgsty/silo-console/pull/62), [pgsty/silo-console#64](https://github.com/pgsty/silo-console/pull/64), [pgsty/silo-console#65](https://github.com/pgsty/silo-console/pull/65), [pgsty/silo-console#66](https://github.com/pgsty/silo-console/pull/66), [pgsty/silo-console#67](https://github.com/pgsty/silo-console/pull/67), [pgsty/silo-console#68](https://github.com/pgsty/silo-console/pull/68), [pgsty/silo-console#69](https://github.com/pgsty/silo-console/pull/69), [pgsty/silo-console#71](https://github.com/pgsty/silo-console/pull/71), [pgsty/silo-console#74](https://github.com/pgsty/silo-console/pull/74), [pgsty/silo-console#79](https://github.com/pgsty/silo-console/pull/79), [pgsty/silo-console#80](https://github.com/pgsty/silo-console/pull/80), [pgsty/silo-console#81](https://github.com/pgsty/silo-console/pull/81), [pgsty/silo-console#82](https://github.com/pgsty/silo-console/pull/82), [pgsty/silo-console#84](https://github.com/pgsty/silo-console/pull/84), [pgsty/silo-console#85](https://github.com/pgsty/silo-console/pull/85), [pgsty/silo-console#86](https://github.com/pgsty/silo-console/pull/86), [pgsty/silo-console#87](https://github.com/pgsty/silo-console/pull/87), [pgsty/silo-console#88](https://github.com/pgsty/silo-console/pull/88) Closed issues: [pgsty/silo-console#1](https://github.com/pgsty/silo-console/issues/1), [pgsty/silo-console#2](https://github.com/pgsty/silo-console/issues/2), [pgsty/silo-console#3](https://github.com/pgsty/silo-console/issues/3), [pgsty/silo-console#4](https://github.com/pgsty/silo-console/issues/4), [pgsty/silo-console#5](https://github.com/pgsty/silo-console/issues/5), [pgsty/silo-console#6](https://github.com/pgsty/silo-console/issues/6), [pgsty/silo-console#7](https://github.com/pgsty/silo-console/issues/7), [pgsty/silo-console#8](https://github.com/pgsty/silo-console/issues/8), [pgsty/silo-console#12](https://github.com/pgsty/silo-console/issues/12), [pgsty/silo-console#13](https://github.com/pgsty/silo-console/issues/13), [pgsty/silo-console#14](https://github.com/pgsty/silo-console/issues/14), [pgsty/silo-console#15](https://github.com/pgsty/silo-console/issues/15), [pgsty/silo-console#16](https://github.com/pgsty/silo-console/issues/16), [pgsty/silo-console#17](https://github.com/pgsty/silo-console/issues/17), [pgsty/silo-console#18](https://github.com/pgsty/silo-console/issues/18), [pgsty/silo-console#19](https://github.com/pgsty/silo-console/issues/19), [pgsty/silo-console#20](https://github.com/pgsty/silo-console/issues/20), [pgsty/silo-console#21](https://github.com/pgsty/silo-console/issues/21), [pgsty/silo-console#22](https://github.com/pgsty/silo-console/issues/22), [pgsty/silo-console#23](https://github.com/pgsty/silo-console/issues/23), [pgsty/silo-console#24](https://github.com/pgsty/silo-console/issues/24), [pgsty/silo-console#25](https://github.com/pgsty/silo-console/issues/25), [pgsty/silo-console#26](https://github.com/pgsty/silo-console/issues/26), [pgsty/silo-console#27](https://github.com/pgsty/silo-console/issues/27), [pgsty/silo-console#28](https://github.com/pgsty/silo-console/issues/28), [pgsty/silo-console#29](https://github.com/pgsty/silo-console/issues/29), [pgsty/silo-console#30](https://github.com/pgsty/silo-console/issues/30), [pgsty/silo-console#31](https://github.com/pgsty/silo-console/issues/31), [pgsty/silo-console#32](https://github.com/pgsty/silo-console/issues/32), [pgsty/silo-console#33](https://github.com/pgsty/silo-console/issues/33), [pgsty/silo-console#34](https://github.com/pgsty/silo-console/issues/34), [pgsty/silo-console#35](https://github.com/pgsty/silo-console/issues/35), [pgsty/silo-console#36](https://github.com/pgsty/silo-console/issues/36), [pgsty/silo-console#37](https://github.com/pgsty/silo-console/issues/37), [pgsty/silo-console#73](https://github.com/pgsty/silo-console/issues/73), [pgsty/silo-console#75](https://github.com/pgsty/silo-console/issues/75), [pgsty/silo-console#76](https://github.com/pgsty/silo-console/issues/76), [pgsty/silo-console#77](https://github.com/pgsty/silo-console/issues/77), [pgsty/silo-console#78](https://github.com/pgsty/silo-console/issues/78) @@ -221,7 +223,7 @@ Closed issues: [pgsty/silo-pkg#13](https://github.com/pgsty/silo-pkg/issues/13) Merged PRs: [pgsty/kes#1](https://github.com/pgsty/kes/pull/1) -Merged PRs: [pgsty/silo.pgsty.com#2](https://github.com/pgsty/silo.pgsty.com/pull/2), [pgsty/silo.pgsty.com#3](https://github.com/pgsty/silo.pgsty.com/pull/3), [pgsty/silo.pgsty.com#5](https://github.com/pgsty/silo.pgsty.com/pull/5), [pgsty/silo.pgsty.com#6](https://github.com/pgsty/silo.pgsty.com/pull/6), [pgsty/silo.pgsty.com#7](https://github.com/pgsty/silo.pgsty.com/pull/7), [pgsty/silo.pgsty.com#8](https://github.com/pgsty/silo.pgsty.com/pull/8), [pgsty/silo.pgsty.com#9](https://github.com/pgsty/silo.pgsty.com/pull/9), [pgsty/silo.pgsty.com#10](https://github.com/pgsty/silo.pgsty.com/pull/10), [pgsty/silo.pgsty.com#11](https://github.com/pgsty/silo.pgsty.com/pull/11), [pgsty/silo.pgsty.com#13](https://github.com/pgsty/silo.pgsty.com/pull/13), [pgsty/silo.pgsty.com#14](https://github.com/pgsty/silo.pgsty.com/pull/14), [pgsty/silo.pgsty.com#15](https://github.com/pgsty/silo.pgsty.com/pull/15), [pgsty/silo.pgsty.com#16](https://github.com/pgsty/silo.pgsty.com/pull/16), [pgsty/silo.pgsty.com#17](https://github.com/pgsty/silo.pgsty.com/pull/17), [pgsty/silo.pgsty.com#19](https://github.com/pgsty/silo.pgsty.com/pull/19), [pgsty/silo.pgsty.com#20](https://github.com/pgsty/silo.pgsty.com/pull/20), [pgsty/silo.pgsty.com#21](https://github.com/pgsty/silo.pgsty.com/pull/21), [pgsty/silo.pgsty.com#22](https://github.com/pgsty/silo.pgsty.com/pull/22), [pgsty/silo.pgsty.com#23](https://github.com/pgsty/silo.pgsty.com/pull/23), [pgsty/silo.pgsty.com#24](https://github.com/pgsty/silo.pgsty.com/pull/24), [pgsty/silo.pgsty.com#25](https://github.com/pgsty/silo.pgsty.com/pull/25), [pgsty/silo.pgsty.com#26](https://github.com/pgsty/silo.pgsty.com/pull/26), [pgsty/silo.pgsty.com#27](https://github.com/pgsty/silo.pgsty.com/pull/27), [pgsty/silo.pgsty.com#28](https://github.com/pgsty/silo.pgsty.com/pull/28), [pgsty/silo.pgsty.com#29](https://github.com/pgsty/silo.pgsty.com/pull/29), [pgsty/silo.pgsty.com#30](https://github.com/pgsty/silo.pgsty.com/pull/30), [pgsty/silo.pgsty.com#31](https://github.com/pgsty/silo.pgsty.com/pull/31), [pgsty/silo.pgsty.com#32](https://github.com/pgsty/silo.pgsty.com/pull/32), [pgsty/silo.pgsty.com#33](https://github.com/pgsty/silo.pgsty.com/pull/33), [pgsty/silo.pgsty.com#34](https://github.com/pgsty/silo.pgsty.com/pull/34), [pgsty/silo.pgsty.com#35](https://github.com/pgsty/silo.pgsty.com/pull/35), [pgsty/silo.pgsty.com#37](https://github.com/pgsty/silo.pgsty.com/pull/37), [pgsty/silo.pgsty.com#38](https://github.com/pgsty/silo.pgsty.com/pull/38), [pgsty/silo.pgsty.com#39](https://github.com/pgsty/silo.pgsty.com/pull/39), [pgsty/silo.pgsty.com#40](https://github.com/pgsty/silo.pgsty.com/pull/40), [pgsty/silo.pgsty.com#41](https://github.com/pgsty/silo.pgsty.com/pull/41), [pgsty/silo.pgsty.com#42](https://github.com/pgsty/silo.pgsty.com/pull/42), [pgsty/silo.pgsty.com#43](https://github.com/pgsty/silo.pgsty.com/pull/43), [pgsty/silo.pgsty.com#44](https://github.com/pgsty/silo.pgsty.com/pull/44), [pgsty/silo.pgsty.com#45](https://github.com/pgsty/silo.pgsty.com/pull/45), [pgsty/silo.pgsty.com#46](https://github.com/pgsty/silo.pgsty.com/pull/46), [pgsty/silo.pgsty.com#47](https://github.com/pgsty/silo.pgsty.com/pull/47), [pgsty/silo.pgsty.com#48](https://github.com/pgsty/silo.pgsty.com/pull/48), [pgsty/silo.pgsty.com#49](https://github.com/pgsty/silo.pgsty.com/pull/49), [pgsty/silo.pgsty.com#50](https://github.com/pgsty/silo.pgsty.com/pull/50), [pgsty/silo.pgsty.com#51](https://github.com/pgsty/silo.pgsty.com/pull/51), [pgsty/silo.pgsty.com#52](https://github.com/pgsty/silo.pgsty.com/pull/52), [pgsty/silo.pgsty.com#53](https://github.com/pgsty/silo.pgsty.com/pull/53) +Merged PRs: [pgsty/silo.pgsty.com#2](https://github.com/pgsty/silo.pgsty.com/pull/2), [pgsty/silo.pgsty.com#3](https://github.com/pgsty/silo.pgsty.com/pull/3), [pgsty/silo.pgsty.com#5](https://github.com/pgsty/silo.pgsty.com/pull/5), [pgsty/silo.pgsty.com#6](https://github.com/pgsty/silo.pgsty.com/pull/6), [pgsty/silo.pgsty.com#7](https://github.com/pgsty/silo.pgsty.com/pull/7), [pgsty/silo.pgsty.com#8](https://github.com/pgsty/silo.pgsty.com/pull/8), [pgsty/silo.pgsty.com#9](https://github.com/pgsty/silo.pgsty.com/pull/9), [pgsty/silo.pgsty.com#10](https://github.com/pgsty/silo.pgsty.com/pull/10), [pgsty/silo.pgsty.com#11](https://github.com/pgsty/silo.pgsty.com/pull/11), [pgsty/silo.pgsty.com#13](https://github.com/pgsty/silo.pgsty.com/pull/13), [pgsty/silo.pgsty.com#14](https://github.com/pgsty/silo.pgsty.com/pull/14), [pgsty/silo.pgsty.com#15](https://github.com/pgsty/silo.pgsty.com/pull/15), [pgsty/silo.pgsty.com#16](https://github.com/pgsty/silo.pgsty.com/pull/16), [pgsty/silo.pgsty.com#17](https://github.com/pgsty/silo.pgsty.com/pull/17), [pgsty/silo.pgsty.com#19](https://github.com/pgsty/silo.pgsty.com/pull/19), [pgsty/silo.pgsty.com#20](https://github.com/pgsty/silo.pgsty.com/pull/20), [pgsty/silo.pgsty.com#21](https://github.com/pgsty/silo.pgsty.com/pull/21), [pgsty/silo.pgsty.com#22](https://github.com/pgsty/silo.pgsty.com/pull/22), [pgsty/silo.pgsty.com#23](https://github.com/pgsty/silo.pgsty.com/pull/23), [pgsty/silo.pgsty.com#24](https://github.com/pgsty/silo.pgsty.com/pull/24), [pgsty/silo.pgsty.com#25](https://github.com/pgsty/silo.pgsty.com/pull/25), [pgsty/silo.pgsty.com#26](https://github.com/pgsty/silo.pgsty.com/pull/26), [pgsty/silo.pgsty.com#27](https://github.com/pgsty/silo.pgsty.com/pull/27), [pgsty/silo.pgsty.com#28](https://github.com/pgsty/silo.pgsty.com/pull/28), [pgsty/silo.pgsty.com#29](https://github.com/pgsty/silo.pgsty.com/pull/29), [pgsty/silo.pgsty.com#30](https://github.com/pgsty/silo.pgsty.com/pull/30), [pgsty/silo.pgsty.com#31](https://github.com/pgsty/silo.pgsty.com/pull/31), [pgsty/silo.pgsty.com#32](https://github.com/pgsty/silo.pgsty.com/pull/32), [pgsty/silo.pgsty.com#33](https://github.com/pgsty/silo.pgsty.com/pull/33), [pgsty/silo.pgsty.com#34](https://github.com/pgsty/silo.pgsty.com/pull/34), [pgsty/silo.pgsty.com#35](https://github.com/pgsty/silo.pgsty.com/pull/35), [pgsty/silo.pgsty.com#37](https://github.com/pgsty/silo.pgsty.com/pull/37), [pgsty/silo.pgsty.com#38](https://github.com/pgsty/silo.pgsty.com/pull/38), [pgsty/silo.pgsty.com#39](https://github.com/pgsty/silo.pgsty.com/pull/39), [pgsty/silo.pgsty.com#40](https://github.com/pgsty/silo.pgsty.com/pull/40), [pgsty/silo.pgsty.com#41](https://github.com/pgsty/silo.pgsty.com/pull/41), [pgsty/silo.pgsty.com#42](https://github.com/pgsty/silo.pgsty.com/pull/42), [pgsty/silo.pgsty.com#43](https://github.com/pgsty/silo.pgsty.com/pull/43), [pgsty/silo.pgsty.com#44](https://github.com/pgsty/silo.pgsty.com/pull/44), [pgsty/silo.pgsty.com#45](https://github.com/pgsty/silo.pgsty.com/pull/45), [pgsty/silo.pgsty.com#46](https://github.com/pgsty/silo.pgsty.com/pull/46), [pgsty/silo.pgsty.com#47](https://github.com/pgsty/silo.pgsty.com/pull/47), [pgsty/silo.pgsty.com#48](https://github.com/pgsty/silo.pgsty.com/pull/48), [pgsty/silo.pgsty.com#49](https://github.com/pgsty/silo.pgsty.com/pull/49), [pgsty/silo.pgsty.com#50](https://github.com/pgsty/silo.pgsty.com/pull/50), [pgsty/silo.pgsty.com#51](https://github.com/pgsty/silo.pgsty.com/pull/51), [pgsty/silo.pgsty.com#52](https://github.com/pgsty/silo.pgsty.com/pull/52), [pgsty/silo.pgsty.com#53](https://github.com/pgsty/silo.pgsty.com/pull/53), [pgsty/silo.pgsty.com#54](https://github.com/pgsty/silo.pgsty.com/pull/54), [pgsty/silo.pgsty.com#55](https://github.com/pgsty/silo.pgsty.com/pull/55), [pgsty/silo.pgsty.com#56](https://github.com/pgsty/silo.pgsty.com/pull/56), [pgsty/silo.pgsty.com#57](https://github.com/pgsty/silo.pgsty.com/pull/57) PRs closed without merging: [pgsty/silo.pgsty.com#36](https://github.com/pgsty/silo.pgsty.com/pull/36) diff --git a/PULL_REQUESTS_ETIQUETTE.md b/PULL_REQUESTS_ETIQUETTE.md index 02dc5e31c..12647563a 100644 --- a/PULL_REQUESTS_ETIQUETTE.md +++ b/PULL_REQUESTS_ETIQUETTE.md @@ -42,7 +42,8 @@ PRs must flow smoothly through review to reach production. Large PRs should be s - Document testing steps in the PR summary if manual testing was performed. 4. **Before Submitting**: - - Run `make verify` to check formatting, linting, and tests. + - Sign off every commit under the Developer Certificate of Origin (`git commit -s`). + - Run `make verifiers` to check formatting, linting, code generation, and `rebrand-guard`. - Reference related issues (e.g., “Closes #1234”). - Notify team members via GitHub `@mentions` if urgent or complex. diff --git a/README.md b/README.md index 902b156ce..4867bfa78 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ PGSTY SILO keeps one maintained release line of the open-source MinIO server alive after upstream ended community distribution: builds, packages, multi-arch images, security fixes, and the full web console. Pigsty runs it in production as its PostgreSQL backup repository. -It follows one rule — **the product and its delivery surfaces are renamed; the protocol and your data are not.** Everything else lives on [silo.pgsty.com](https://silo.pgsty.com/). +It follows one rule: **the product and its delivery surfaces are renamed; the protocol and your data are not.** Full documentation is published at [silo.pgsty.com](https://silo.pgsty.com/). **Related:** [`pgsty/mc`](https://github.com/pgsty/mc) · [`pgsty/silo-console`](https://github.com/pgsty/silo-console) · [`pgsty/silo-pkg`](https://github.com/pgsty/silo-pkg) · [`pgsty/kes`](https://github.com/pgsty/kes) · [`pgsty/pigsty`](https://github.com/pgsty/pigsty) @@ -44,10 +44,10 @@ It follows one rule — **the product and its delivery surfaces are renamed; the Silo Console

-#### SILO 20261109 +### SILO 20261109 > [!WARNING] -> [SILO 20261109](https://github.com/pgsty/silo/releases/tag/RELEASE.2026-11-09T00-00-00Z) fixes bugs and security vulnerabilities. We strongly recommend scheduling an upgrade to reduce security risks. +> [SILO 20261109](https://github.com/pgsty/silo/releases/tag/RELEASE.2026-11-09T00-00-00Z) (`20261109000000.0.0`, source `d998292387cc`) fixes storage, lifecycle, native restore, Object Lock, batch authorization, and IAM defects, embedding Console `v2.7.0` with `mcli 20261109`, `silo-pkg v3.16.0`, and Go `1.27.3`. Review the [release notes and upgrade requirements](https://silo.pgsty.com/blog/release/silo-20261109/) before upgrading. > [!NOTE] > On 2026-08-06, `pgsty/minio` became `pgsty/silo`, and the default branch changed from `master` to `main`. The last MinIO-branded release is [`20260804`](https://github.com/pgsty/silo/releases/tag/RELEASE.2026-08-04T00-00-00Z) on the [`minio`](https://github.com/pgsty/silo/tree/minio) branch. @@ -86,7 +86,7 @@ docker exec silo mcli mb local/demo && docker exec silo mcli ls local | Kubernetes | Helm chart, see [Download & Install](https://silo.pgsty.com/download/) | | Source | `go build -o silo . && ./silo --version` | -Every release ships checksums, SPDX SBOMs, Sigstore-signed manifests, and GitHub build attestations. Installation methods and verification commands are documented at [Download & Install](https://silo.pgsty.com/download/); migrating from upstream MinIO — taking over an existing `minio.service` and its `/etc/default/minio`, and keeping data ownership stable with a `/etc/systemd/system/silo.service.d/10-legacy-user.conf` drop-in — is covered by the [migration guide](https://silo.pgsty.com/compatibility/migration/) and the [binary & service notes](https://silo.pgsty.com/compatibility/binary/). +Every release ships checksums, SPDX SBOMs, Sigstore-signed manifests, and GitHub build attestations. Installation methods and verification commands are documented at [Download & Install](https://silo.pgsty.com/download/). To migrate from upstream MinIO, take over `minio.service` and `/etc/default/minio`, and preserve data ownership with a `/etc/systemd/system/silo.service.d/10-legacy-user.conf` drop-in. See the [migration guide](https://silo.pgsty.com/compatibility/migration/) and [binary & service notes](https://silo.pgsty.com/compatibility/binary/). ## Compatibility @@ -98,7 +98,7 @@ Every divergence from upstream is listed in the code-verified [compatibility aud ### TLS and Go upgrades -Server 20260916 restored Go's default key exchange; Server 20261018 retains it with Go 1.27.2. +Server 20260916 restored Go's default key exchange; Server 20261109 retains it with Go 1.27.3. TLS key exchange follows Go's defaults across the S3 listener, node links, replication, identity providers, etcd, and external HTTP services. If an endpoint cannot accept ML-KEM, `GODEBUG=tlsmlkem=0` disables the default hybrid exchanges diff --git a/README_ZH.md b/README_ZH.md index aa3227566..9d470c8b5 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -36,7 +36,7 @@ 上游停止社区发行后,Silo 为开源 MinIO 服务端维护一条持续可用的版本线:构建、软件包、多架构镜像、安全修复与完整 Web 控制台。Pigsty 在生产环境中用它承载 PostgreSQL 备份存储。 -它只遵循一条原则:**改名的是产品与交付物,不是协议与你的数据。** 其余内容都在 [silo.pgsty.com](https://silo.pgsty.com/zh/)。 +它只遵循一条原则:**改名的是产品与交付物,不是协议与你的数据。** 完整文档发布在 [silo.pgsty.com](https://silo.pgsty.com/zh/)。 **相关项目:**[`pgsty/mc`](https://github.com/pgsty/mc) · [`pgsty/silo-console`](https://github.com/pgsty/silo-console) · [`pgsty/silo-pkg`](https://github.com/pgsty/silo-pkg) · [`pgsty/kes`](https://github.com/pgsty/kes) · [`pgsty/pigsty`](https://github.com/pgsty/pigsty) @@ -44,10 +44,10 @@ Silo 控制台

-#### SILO 20261109 +### SILO 20261109 > [!WARNING] -> [SILO 20261109](https://github.com/pgsty/silo/releases/tag/RELEASE.2026-11-09T00-00-00Z) 修复了缺陷与安全漏洞,强烈建议尽快安排升级,以降低安全风险。 +> [SILO 20261109](https://github.com/pgsty/silo/releases/tag/RELEASE.2026-11-09T00-00-00Z)(包版本 `20261109000000.0.0`,源码 `d998292387cc`)修复了多池生命周期、原生恢复、Object Lock、批处理授权与 IAM 等缺陷,内嵌 Console `v2.7.0` 并配套 `mcli 20261109`、`silo-pkg v3.16.0` 与 Go `1.27.3`。升级前请阅读[发布说明与升级要求](https://silo.pgsty.com/zh/blog/release/silo-20261109/)。 > [!NOTE] > 2026-08-06,`pgsty/minio` 更名为 `pgsty/silo`,默认分支由 `master` 改为 `main`。最后一个 MinIO 品牌发布是 [`minio`](https://github.com/pgsty/silo/tree/minio) 分支上的 [`20260804`](https://github.com/pgsty/silo/releases/tag/RELEASE.2026-08-04T00-00-00Z)。 @@ -98,9 +98,9 @@ Silo 保留 S3 与存储格式兼容性,包括既有 `MINIO_*` 环境变量、 ### TLS 与 Go 升级 -Server 20260916 已恢复 Go 默认密钥交换策略,20261018 在 Go 1.27.2 下保留该行为。 -`GODEBUG=tlsmlkem=0`、`tlssecpmlkem=0` 的适用范围、macOS 根证书来源变化, -以及 OIDC discovery 的诊断方法见 [Go 1.27 TLS 与 OIDC 指南](https://silo.pgsty.com/zh/blog/design/go127-tls-oidc-discovery/)。 +Server 20260916 恢复了 Go 默认密钥交换策略,Server 20261109 在 Go 1.27.3 下保留该行为。S3 监听端口、节点间连接、站点与桶复制、身份提供方、etcd 及外部 HTTP 服务均使用 Go 默认 TLS 密钥交换。若对端无法接受 ML-KEM,设置 `GODEBUG=tlsmlkem=0` 可在当前进程禁用默认混合密钥交换(证书校验保持开启);该选项不会禁用 ML-DSA 签名,也不能解决所有 TLS 连接重置问题,建议在升级不兼容对端后移除该临时配置。若仅新增的 SecP 混合组引发问题,可设置 `GODEBUG=tlssecpmlkem=0` 仅禁用这些组并保留 `X25519MLKEM768`。 + +在 Go 1.27 构建中,macOS 上设置 `SSL_CERT_FILE` 或 `SSL_CERT_DIR` 会以磁盘根证书和 Go 验证器替代系统钥匙串信任;陈旧或不完整的 CA 路径可能导致原先受信任的连接失败,清除继承的环境变量即可恢复钥匙串信任。配置的 `CAs` 目录中的显式证书仍会叠加加入所选根证书池。Go 1.27 二进制要求 macOS 13 或更高版本。详见 [Go 1.27 发行说明](https://go.dev/doc/go1.27)与 [Go 1.27 TLS 与 OIDC 发现指南](https://silo.pgsty.com/zh/blog/design/go127-tls-oidc-discovery/)。 ## 文档归属 diff --git a/SECURITY.md b/SECURITY.md index ba658a04c..90787a27d 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -38,7 +38,7 @@ For vulnerabilities in this fork: 1. Follow the fork-specific expectations in [VULNERABILITY_REPORT.md](VULNERABILITY_REPORT.md). 2. Prefer this repository's [private GitHub security advisory](https://github.com/pgsty/silo/security/advisories/new) workflow. -3. If you cannot use GitHub private reporting, email [ron@pgsty.com](mailto:ron@pgsty.com) or rh@vonng.com +3. If you cannot use GitHub private reporting, email [ron@pgsty.com](mailto:ron@pgsty.com) or [rh@vonng.com](mailto:rh@vonng.com) directly. Do not publish vulnerability details or exploit code in public issues or pull requests. 4. If you confirm the issue also affects upstream `minio/minio`, report it upstream separately. diff --git a/docs/metrics/healthcheck/README.md b/docs/metrics/healthcheck/README.md index 88fb19136..716ce4ff1 100644 --- a/docs/metrics/healthcheck/README.md +++ b/docs/metrics/healthcheck/README.md @@ -1,6 +1,6 @@ # Silo Healthcheck -Silo server exposes three un-authenticated, healthcheck endpoints liveness probe and a cluster probe at `/minio/health/live` and `/minio/health/cluster` respectively. +Silo server exposes four unauthenticated healthcheck endpoints: `/minio/health/live`, `/minio/health/ready`, `/minio/health/cluster`, and `/minio/health/cluster/read`. ## Native CLI probe @@ -76,7 +76,7 @@ readinessProbe: The reply is '200 OK' if cluster has write quorum if not it returns '503 Service Unavailable'. ``` -curl http://silo1:9001/minio/health/cluster +curl http://silo1:9000/minio/health/cluster HTTP/1.1 503 Service Unavailable Accept-Ranges: bytes Content-Length: 0 @@ -94,7 +94,7 @@ Date: Tue, 21 Jul 2020 00:36:14 GMT The reply is '200 OK' if cluster has read quorum if not it returns '503 Service Unavailable'. ``` -curl http://silo1:9001/minio/health/cluster/read +curl http://silo1:9000/minio/health/cluster/read HTTP/1.1 503 Service Unavailable Accept-Ranges: bytes Content-Length: 0 @@ -112,7 +112,7 @@ Date: Tue, 21 Jul 2020 00:36:14 GMT You may query the cluster probe endpoint to check if the node which received the request can be taken down for maintenance, if the server replies back '412 Precondition Failed' this means you will lose HA. '200 OK' means you are okay to proceed. ``` -curl http://silo1:9001/minio/health/cluster?maintenance=true +curl http://silo1:9000/minio/health/cluster?maintenance=true HTTP/1.1 412 Precondition Failed Accept-Ranges: bytes Content-Length: 0 From 6a65b43076544b481239d9aec69b06f5540bd29a Mon Sep 17 00:00:00 2001 From: Feng Ruohang Date: Tue, 10 Nov 2026 11:00:05 +0800 Subject: [PATCH 3/5] docs(helm): fix NOTES connection commands and document 20261109 upgrade gates Signed-off-by: Feng Ruohang --- helm/silo/README.md | 20 +++++++++++++++++--- helm/silo/templates/NOTES.txt | 33 +++++++++++++++++---------------- helm/silo/values.yaml | 4 ++-- 3 files changed, 36 insertions(+), 21 deletions(-) diff --git a/helm/silo/README.md b/helm/silo/README.md index 6b1d293e4..255fc81db 100644 --- a/helm/silo/README.md +++ b/helm/silo/README.md @@ -34,9 +34,10 @@ helm install silo ./helm/silo \ --set rootPassword='replace-with-a-long-random-secret' ``` -The default server and post-install image is `docker.io/pgsty/silo`. The image -contains the `silo` server, `mcli`, and an `mc` compatibility symlink. It does -not contain a `minio` server binary. +The default server image is `docker.io/pgsty/silo` (which contains the `silo` +server, `mcli`, and an `mc` client compatibility symlink, without a `minio` +server binary). Post-install resource jobs use `mcImage` (`docker.io/pgsty/mc`) +by default. ## Compatibility contract @@ -100,6 +101,19 @@ new chart invokes `silo server`; an old image does not provide that executable. The new image accepts a legacy first argv token of `minio` only to support old chart commands during the forward migration. +When upgrading an existing Silo release to `RELEASE.2026-11-09T00-00-00Z` (chart +`7.0.4`): + +- `helm upgrade --reuse-values` keeps the previously saved `image.tag` and + `mcImage.tag`; pass `--set-string image.tag=RELEASE.2026-11-09T00-00-00Z` and + `--set-string mcImage.tag=RELEASE.2026-11-09T00-00-00Z` explicitly. +- Drain or cancel active batch jobs before upgrading; `20261026` cannot resume + new submitter-bound batch manifests on downgrade. +- Single-pool clusters whose erasure sets do not all share the same pods/hosts + require a drained stop-all upgrade rather than a rolling pod restart, and + `POST ?restore` returns `503` while mixed versions are serving. See the + [20261109 release notes](https://silo.pgsty.com/blog/release/silo-20261109/#upgrade). + ## Persistence and TLS The default PersistentVolumeClaim mounts at `/export`. Use an existing claim diff --git a/helm/silo/templates/NOTES.txt b/helm/silo/templates/NOTES.txt index 7aa734c9d..23e445486 100644 --- a/helm/silo/templates/NOTES.txt +++ b/helm/silo/templates/NOTES.txt @@ -1,43 +1,44 @@ +{{- $scheme := ternary "https" "http" .Values.tls.enabled -}} {{- if eq .Values.service.type "ClusterIP" "NodePort" }} Silo can be accessed via port {{ .Values.service.port }} on the following DNS name from within your cluster: -{{ template "silo.fullname" . }}.{{ .Release.Namespace }}.{{ .Values.clusterDomain }} +{{ template "silo.fullname" . }}.{{ .Release.Namespace }}.svc.{{ .Values.clusterDomain }} -To access Silo from localhost, run the below commands: +To access Silo from localhost, run the commands below: 1. export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "release={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}") - 2. kubectl port-forward $POD_NAME 9000 --namespace {{ .Release.Namespace }} + 2. kubectl port-forward $POD_NAME {{ .Values.service.port }}:{{ .Values.minioAPIPort | default 9000 }} --namespace {{ .Release.Namespace }} -Read more about port forwarding here: http://kubernetes.io/docs/user-guide/kubectl/kubectl_port-forward/ +Read more about port forwarding here: https://kubernetes.io/docs/tasks/access-application-cluster/port-forward-access-application-cluster/ -You can now access Silo server on http://localhost:9000. Follow the below steps to connect to Silo server with mc client: +You can now access the Silo server on {{ $scheme }}://localhost:{{ .Values.service.port }}. Connect with the mcli client: - 1. Download the Silo mc client - https://silo.pgsty.com/reference/minio-mc/#quickstart + 1. Download mcli: https://silo.pgsty.com/download/#client - 2. export MC_HOST_{{ template "silo.fullname" . }}_local=http://$(kubectl get secret --namespace {{ .Release.Namespace }} {{ template "silo.secretName" . }} -o jsonpath="{.data.rootUser}" | base64 --decode):$(kubectl get secret --namespace {{ .Release.Namespace }} {{ template "silo.secretName" . }} -o jsonpath="{.data.rootPassword}" | base64 --decode)@localhost:{{ .Values.service.port }} + 2. mcli alias set local {{ $scheme }}://localhost:{{ .Values.service.port }} "$(kubectl get secret --namespace {{ .Release.Namespace }} {{ template "silo.secretName" . }} -o jsonpath="{.data.rootUser}" | base64 --decode)" "$(kubectl get secret --namespace {{ .Release.Namespace }} {{ template "silo.secretName" . }} -o jsonpath="{.data.rootPassword}" | base64 --decode)" - 3. mc ls {{ template "silo.fullname" . }}_local + 3. mcli ls local {{- end }} {{- if eq .Values.service.type "LoadBalancer" }} Silo can be accessed via port {{ .Values.service.port }} on an external IP address. Get the service external IP address by: -kubectl get svc --namespace {{ .Release.Namespace }} -l app={{ template "silo.fullname" . }} +kubectl get svc --namespace {{ .Release.Namespace }} -l "app={{ template "silo.name" . }},release={{ .Release.Name }}" -Note that the public IP may take a couple of minutes to be available. +Note that the public IP may take a couple of minutes to become available. -You can now access Silo server on http://:9000. Follow the below steps to connect to Silo server with mc client: +You can now access the Silo server on {{ $scheme }}://:{{ .Values.service.port }}. Connect with the mcli client: - 1. Download the Silo mc client - https://silo.pgsty.com/reference/minio-mc/#quickstart + 1. Download mcli: https://silo.pgsty.com/download/#client - 2. export MC_HOST_{{ template "silo.fullname" . }}_local=http://$(kubectl get secret {{ template "silo.secretName" . }} --namespace {{ .Release.Namespace }} -o jsonpath="{.data.rootUser}" | base64 --decode):$(kubectl get secret {{ template "silo.secretName" . }} -o jsonpath="{.data.rootPassword}" | base64 --decode)@:{{ .Values.service.port }} + 2. mcli alias set external {{ $scheme }}://:{{ .Values.service.port }} "$(kubectl get secret {{ template "silo.secretName" . }} --namespace {{ .Release.Namespace }} -o jsonpath="{.data.rootUser}" | base64 --decode)" "$(kubectl get secret {{ template "silo.secretName" . }} --namespace {{ .Release.Namespace }} -o jsonpath="{.data.rootPassword}" | base64 --decode)" - 3. mc ls {{ template "silo.fullname" . }} + 3. mcli ls external -Alternately, use a browser, an S3 SDK, or a MinIO-compatible SDK to access Silo - https://silo.pgsty.com/reference/minio-server/minio-server/ +Alternately, use a browser, an S3 SDK, or a MinIO-compatible SDK to access Silo: https://silo.pgsty.com/reference/minio-server/ {{- end }} {{ if and (.Values.networkPolicy.enabled) (not .Values.networkPolicy.allowExternal) }} Note: Since NetworkPolicy is enabled, only pods with label -{{ template "silo.fullname" . }}-client=true" +{{ template "silo.fullname" . }}-client=true will be able to connect to this Silo cluster. {{- end }} diff --git a/helm/silo/values.yaml b/helm/silo/values.yaml index a911417a6..c7ee7ded4 100644 --- a/helm/silo/values.yaml +++ b/helm/silo/values.yaml @@ -11,7 +11,7 @@ fullnameOverride: "" ## clusterDomain: cluster.local -## Set default image, imageTag, and imagePullPolicy. mode is used to indicate the +## Set default Silo server image repository, tag, and pull policy. ## image: repository: pgsty/silo @@ -517,7 +517,7 @@ postJob: ## Add environment variables for Silo. The MINIO_* names remain the stable ## configuration interface and are passed to the Silo container(s). environment: - ## Please refer for comprehensive list https://silo.pgsty.com/reference/minio-server/minio-server/ + ## See https://silo.pgsty.com/reference/minio-server/settings/ for available settings. ## MINIO_BROWSER: "off" ## The name of a secret in the same kubernetes namespace which contain secret values From 63650a31a104c78d24fe9d6f47cde8dbe24ea5bf Mon Sep 17 00:00:00 2001 From: Feng Ruohang Date: Tue, 10 Nov 2026 09:42:43 +0800 Subject: [PATCH 4/5] fix(iam): keep a service account's inline policy on an edit that names none An update-service-account request without a policy removed the inline policy of the account: the store took a nil session policy for an empty one, and the handler turned an empty one into nil, so "not given" and "given, empty" were one case. A status change, a secret key rotation, a rename or a new expiry (mcli admin user svcacct disable, enable, and edit without --policy, and the idp ldap accesskey equivalents) signed the token again as inherited-policy, and the account then had all its parent is allowed. A replication peer did the same with the forwarded edit. A nil session policy now leaves the policy as it is. The handler passes an empty policy document on as such, and only that removes the inline policy, as mcli admin user svcacct edit --policy with {"Statement":[]} already asks for. A JSON null policy counts as none. The peer handler passes nil for an edit without a policy and needs no change. What an update does with any other policy document is unchanged. The behaviour came with upstream f0462322f (minio/minio#14847). Signed-off-by: Feng Ruohang Co-Authored-By: Claude Fable 5.2 --- CHANGELOG.md | 17 +++ cmd/admin-handlers-users.go | 8 +- cmd/iam-store.go | 11 +- cmd/iam-svcacct-policy-edit_test.go | 227 ++++++++++++++++++++++++++++ 4 files changed, 254 insertions(+), 9 deletions(-) create mode 100644 cmd/iam-svcacct-policy-edit_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 20d7d0aaf..12c5c73e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,23 @@ delete marker on an object of another bucket or on stored system metadata such as a bucket's configuration. The other entries of a request are deleted as before. +- An edit of a service account or access key that names no policy keeps its + inline policy. `mcli admin user svcacct disable`, `enable` and `edit` without + `--policy`, the same `idp ldap accesskey` commands, and any other + `update-service-account` request without `newPolicy` removed the inline + policy at the site that took the request and, for an account of another user + than root, at its replication peers, and the account then had all its parent + is allowed. The inline policy is removed + only on request, by an empty policy document such as `{"Statement":[]}` + (`mcli admin user svcacct edit --policy`); a client that left the policy out + to remove it must send that document. A JSON `null` policy counts as none. + In the Console, clearing the policy editor of an access key no longer removes + the policy; enter `{}` to remove it. The fix does not bring back a policy + that was removed, and a site of a replicated deployment that does not run the + fix still removes the policy when such an edit reaches it, so sites can + differ. Upgrade every site, then review on each site the accounts that + `svcacct info` reports with `Policy: implied`, and set the policy again on + those that were meant to be restricted. ## RELEASE.2026-11-09T00-00-00Z diff --git a/cmd/admin-handlers-users.go b/cmd/admin-handlers-users.go index 7be574e56..db9764359 100644 --- a/cmd/admin-handlers-users.go +++ b/cmd/admin-handlers-users.go @@ -885,16 +885,16 @@ func (a adminAPIHandlers) UpdateServiceAccount(w http.ResponseWriter, r *http.Re return } + // A request without a policy, or with a JSON null for one, keeps the + // policy the account has. An empty policy document is passed on as + // such: it is the request to remove the embedded policy. var sp *policy.Policy - if len(updateReq.NewPolicy) > 0 { + if len(updateReq.NewPolicy) > 0 && !bytes.Equal(updateReq.NewPolicy, []byte("null")) { sp, err = policy.ParseConfigStrict(bytes.NewReader(updateReq.NewPolicy)) if err != nil { writeErrorResponseJSON(ctx, w, toAdminAPIErr(ctx, err), r.URL) return } - if sp.Version == "" && len(sp.Statements) == 0 { - sp = nil - } } opts := updateServiceAccountOpts{ secretKey: updateReq.NewSecretKey, diff --git a/cmd/iam-store.go b/cmd/iam-store.go index 3707e883f..685fa46a8 100644 --- a/cmd/iam-store.go +++ b/cmd/iam-store.go @@ -2930,11 +2930,12 @@ func updatedServiceAccountCredentials(ctx context.Context, accessKey string, cr // at this point. m.Delete(sessionPolicyNameExtracted) - nosp := opts.sessionPolicy == nil || opts.sessionPolicy.Version == "" && len(opts.sessionPolicy.Statements) == 0 - - // sessionPolicy is nil and there is embedded policy attached we remove - // embedded policy at that point. - if _, ok := m.Lookup(policy.SessionPolicyName); ok && nosp { + // A nil sessionPolicy is an edit that does not concern the policy, and + // the embedded one stays. Only a policy that was given, and is empty, + // removes it: the account then has all its parent is allowed, which is + // usually more. + reset := opts.sessionPolicy != nil && opts.sessionPolicy.Version == "" && len(opts.sessionPolicy.Statements) == 0 + if _, ok := m.Lookup(policy.SessionPolicyName); ok && reset { m.Delete(policy.SessionPolicyName) m.Set(iamPolicyClaimNameSA(), inheritedPolicyType) } diff --git a/cmd/iam-svcacct-policy-edit_test.go b/cmd/iam-svcacct-policy-edit_test.go new file mode 100644 index 000000000..5d00c6b75 --- /dev/null +++ b/cmd/iam-svcacct-policy-edit_test.go @@ -0,0 +1,227 @@ +// Copyright (c) 2026 PGSTY +// SPDX-License-Identifier: AGPL-3.0-or-later + +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "runtime" + "strings" + "testing" + "time" + + "github.com/minio/madmin-go/v3" + minio "github.com/minio/minio-go/v7" + "github.com/minio/minio/internal/auth" + "github.com/pgsty/silo-pkg/v3/policy" +) + +// An edit of a service account that names no policy keeps the embedded +// policy: the account must not come out of a status change, a key rotation +// or a rename with all its parent is allowed. Only a policy that is given +// and empty removes the embedded one. + +const svcAccEditPolicy = `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:GetObject"],"Resource":["arn:aws:s3:::edit/*"]}]}` + +func TestIAMServiceAccountEditKeepsPolicy(t *testing.T) { + f := newGroupRefreshFixture(t) + want, err := policy.ParseConfig(strings.NewReader(svcAccEditPolicy)) + mustIAM(t, err) + + // restricted creates a service account with the embedded policy. + restricted := func(t *testing.T, key, status string) { + t.Helper() + _, _, err := f.sys.NewServiceAccount(f.ctx, f.parent.AccessKey, nil, newServiceAccountOpts{accessKey: key, secretKey: "edit-original-secret", status: status, sessionPolicy: want}) + mustIAM(t, err) + } + // embedded reports whether this server and storage have the policy, and + // returns the stored credential. + embedded := func(t *testing.T, key string) (cached, stored bool, cr auth.Credentials) { + t.Helper() + _, got, err := f.sys.GetServiceAccount(f.ctx, key) + mustIAM(t, err) + if got != nil && !got.Equals(*want) { + t.Fatalf("the embedded policy changed: %+v", got) + } + u, ok := f.stored(t, key) + if !ok { + t.Fatal("service account missing in storage") + } + claims, err := getClaimsFromTokenWithSecret(u.Credentials.SessionToken, u.Credentials.SecretKey) + mustIAM(t, err) + _, hasPolicy := claims.Lookup(policy.SessionPolicyName) + return got != nil, hasPolicy && claims.MapClaims[iamPolicyClaimNameSA()] == embeddedPolicyType, u.Credentials + } + // check wants the edit applied, and the policy kept or removed. + check := func(t *testing.T, key string, keep bool, took func(auth.Credentials) bool) { + t.Helper() + cached, stored, cr := embedded(t, key) + if cached != keep || stored != keep { + t.Errorf("embedded policy: cached=%v stored=%v, want %v", cached, stored, keep) + } + if took != nil && !took(cr) { + t.Errorf("the edit did not apply: status=%q name=%q description=%q expiration=%v", cr.Status, cr.Name, cr.Description, cr.Expiration) + } + } + + later := UTCNow().Add(time.Hour) + for _, edit := range []struct { + name, start string + opts updateServiceAccountOpts + took func(auth.Credentials) bool + }{ + {"disable", auth.AccountOn, updateServiceAccountOpts{status: string(madmin.AccountDisabled)}, func(c auth.Credentials) bool { return c.Status == auth.AccountOff }}, + {"enable", auth.AccountOff, updateServiceAccountOpts{status: string(madmin.AccountEnabled)}, func(c auth.Credentials) bool { return c.Status == auth.AccountOn }}, + {"status-off", auth.AccountOn, updateServiceAccountOpts{status: auth.AccountOff}, func(c auth.Credentials) bool { return c.Status == auth.AccountOff }}, + {"status-on", auth.AccountOff, updateServiceAccountOpts{status: auth.AccountOn}, func(c auth.Credentials) bool { return c.Status == auth.AccountOn }}, + {"secret-key", auth.AccountOn, updateServiceAccountOpts{secretKey: "edit-rotated-secret"}, func(c auth.Credentials) bool { return c.SecretKey == "edit-rotated-secret" }}, + {"name", auth.AccountOn, updateServiceAccountOpts{name: "renamed"}, func(c auth.Credentials) bool { return c.Name == "renamed" }}, + {"description", auth.AccountOn, updateServiceAccountOpts{description: "described"}, func(c auth.Credentials) bool { return c.Description == "described" }}, + {"expiration", auth.AccountOn, updateServiceAccountOpts{expiration: &later}, func(c auth.Credentials) bool { return c.Expiration.Equal(later) }}, + } { + t.Run(edit.name, func(t *testing.T) { + key := "edit-" + edit.name + restricted(t, key, edit.start) + _, err := f.sys.UpdateServiceAccount(f.ctx, key, edit.opts) + mustIAM(t, err) + check(t, key, true, edit.took) + }) + } + + t.Run("empty-policy", func(t *testing.T) { + const key = "edit-empty-policy" + restricted(t, key, auth.AccountOn) + _, err := f.sys.UpdateServiceAccount(f.ctx, key, updateServiceAccountOpts{sessionPolicy: &policy.Policy{}}) + mustIAM(t, err) + check(t, key, false, nil) + }) + + // A peer applies the edit as the site that took it did. The edit goes + // through the encoding of the wire, which is where no policy and a null + // one become the same. + for _, edit := range []struct { + name string + update madmin.SRSvcAccUpdate + keep bool + took func(auth.Credentials) bool + }{ + {"peer-status", madmin.SRSvcAccUpdate{Status: auth.AccountOff}, true, func(c auth.Credentials) bool { return c.Status == auth.AccountOff }}, + {"peer-null", madmin.SRSvcAccUpdate{Description: "described", SessionPolicy: madmin.SRSessionPolicy("null")}, true, func(c auth.Credentials) bool { return c.Description == "described" }}, + {"peer-empty", madmin.SRSvcAccUpdate{SessionPolicy: madmin.SRSessionPolicy(`{"Statement":[]}`)}, false, nil}, + } { + t.Run(edit.name, func(t *testing.T) { + key := "edit-" + edit.name + restricted(t, key, auth.AccountOn) + edit.update.AccessKey = key + sent, err := json.Marshal(iamReplicationItem{SRIAMItem: madmin.SRIAMItem{ + Type: madmin.SRIAMItemSvcAcc, + SvcAccChange: &madmin.SRSvcAccChange{Update: &edit.update}, + UpdatedAt: UTCNow(), + }}) + mustIAM(t, err) + var got iamReplicationItem + mustIAM(t, json.Unmarshal(sent, &got)) + mustIAM(t, applyIAMReplicationItem(f.ctx, got)) + check(t, key, edit.keep, edit.took) + }) + } +} + +// The same over the admin API, with what a client can send for no policy. +func TestServiceAccountUpdateKeepsPolicy(t *testing.T) { + if runtime.GOOS == globalWindowsOSName { + t.Skip("IAM integration harness is disabled on Windows") + } + s := newTestSuiteIAM(TestSuiteCommon{serverType: "ErasureSD", signer: signerV4}, false) + c := &check{t, s.serverType} + s.SetUpSuite(c) + defer s.TearDownSuite(c) + + ctx, cancel := context.WithTimeout(context.Background(), testDefaultTimeout) + defer cancel() + + bucket := getRandomBucketName() + if err := s.client.MakeBucket(ctx, bucket, minio.MakeBucketOptions{}); err != nil { + c.Fatalf("bucket create error: %v", err) + } + if err := s.adm.AddCannedPolicy(ctx, "keeps-policy-parent", fmt.Appendf(nil, `{"Version":"2012-10-17","Statement":[ +{"Effect":"Allow","Action":["s3:ListBucket"],"Resource":["arn:aws:s3:::%s"]}, +{"Effect":"Allow","Action":["s3:PutObject","s3:GetObject"],"Resource":["arn:aws:s3:::%s/*"]}]}`, bucket, bucket)); err != nil { + c.Fatalf("policy add error: %v", err) + } + parentAK, parentSK := mustGenerateCredentials(c) + if err := s.adm.SetUser(ctx, parentAK, parentSK, madmin.AccountEnabled); err != nil { + c.Fatalf("unable to set user: %v", err) + } + if _, err := s.adm.AttachPolicy(ctx, madmin.PolicyAssociationReq{Policies: []string{"keeps-policy-parent"}, User: parentAK}); err != nil { + c.Fatalf("unable to attach policy: %v", err) + } + + // The account may read objects; listing is its parent's to do. + svcAK, svcSK := mustGenerateCredentials(c) + cr, err := s.adm.AddServiceAccount(ctx, madmin.AddServiceAccountReq{ + Policy: fmt.Appendf(nil, `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:GetObject"],"Resource":["arn:aws:s3:::%s/*"]}]}`, bucket), + TargetUser: parentAK, + AccessKey: svcAK, + SecretKey: svcSK, + }) + if err != nil { + c.Fatalf("unable to create svc acc: %v", err) + } + c.mustUpload(ctx, s.client, bucket) + svcClient := s.getUserClient(c, cr.AccessKey, cr.SecretKey, "") + c.mustDownload(ctx, svcClient, bucket) + c.mustNotListObjects(ctx, svcClient, bucket) + + info := func() madmin.InfoServiceAccountResp { + info, err := s.adm.InfoServiceAccount(ctx, svcAK) + if err != nil { + c.Fatalf("unable to get svc acc info: %v", err) + } + return info + } + + _, rotatedSK := mustGenerateCredentials(c) + later := UTCNow().Add(time.Hour).Truncate(time.Second) + for _, edit := range []struct { + name string + req madmin.UpdateServiceAccountReq + took func(madmin.InfoServiceAccountResp) bool + }{ + {"disable", madmin.UpdateServiceAccountReq{NewStatus: "off"}, func(i madmin.InfoServiceAccountResp) bool { return i.AccountStatus == auth.AccountOff }}, + {"enable", madmin.UpdateServiceAccountReq{NewStatus: "on"}, func(i madmin.InfoServiceAccountResp) bool { return i.AccountStatus == auth.AccountOn }}, + {"description", madmin.UpdateServiceAccountReq{NewDescription: "described"}, func(i madmin.InfoServiceAccountResp) bool { return i.Description == "described" }}, + {"name", madmin.UpdateServiceAccountReq{NewName: "renamed"}, func(i madmin.InfoServiceAccountResp) bool { return i.Name == "renamed" }}, + {"expiry", madmin.UpdateServiceAccountReq{NewExpiration: &later}, func(i madmin.InfoServiceAccountResp) bool { return i.Expiration != nil && i.Expiration.Equal(later) }}, + {"secret-key", madmin.UpdateServiceAccountReq{NewSecretKey: rotatedSK}, func(madmin.InfoServiceAccountResp) bool { + // The new key signs a request the embedded policy allows. + svcClient = s.getUserClient(c, svcAK, rotatedSK, "") + _, err := svcClient.StatObject(ctx, bucket, "some-object", minio.StatObjectOptions{}) + return err == nil + }}, + {"null-policy", madmin.UpdateServiceAccountReq{NewPolicy: json.RawMessage("null"), NewDescription: "null"}, func(i madmin.InfoServiceAccountResp) bool { return i.Description == "null" }}, + } { + if err := s.adm.UpdateServiceAccount(ctx, svcAK, edit.req); err != nil { + c.Fatalf("%s: unable to update svc acc: %v", edit.name, err) + } + got := info() + if got.ImpliedPolicy { + c.Fatalf("%s: the account lost its embedded policy", edit.name) + } + if !edit.took(got) { + c.Fatalf("%s: the edit did not apply: %+v", edit.name, got) + } + } + c.mustNotListObjects(ctx, svcClient, bucket) + + // An empty policy document asks for the parent's policies. + if err := s.adm.UpdateServiceAccount(ctx, svcAK, madmin.UpdateServiceAccountReq{NewPolicy: []byte(`{"Statement":[]}`)}); err != nil { + c.Fatalf("unable to remove the policy of svc acc: %v", err) + } + if !info().ImpliedPolicy { + c.Fatalf("an empty policy document must remove the embedded policy") + } + c.mustListObjects(ctx, svcClient, bucket) +} From da8d8a54b1f0128c9f89b3ee5c6845cf05490440 Mon Sep 17 00:00:00 2001 From: Feng Ruohang Date: Tue, 10 Nov 2026 13:46:57 +0800 Subject: [PATCH 5/5] fix(iam): replicate a service account as the record that is stored Site replication sent an edit of a service account as the fields the request changed. A peer orders the items of an account by revision and drops an older one, which is right for a whole account and loses data for a part of one: when an edit did not reach a peer, or reached it after a later edit, the peer kept the old secret key, status or inline policy under the revision of the later edit, and healing took the sites for equal. Two requests at one site sufficed, as the sender dropped the item of the first when the second was already stored. The first sync of a new site, healing and IAM export put an account together from two places: the revision from storage, and the claims and the inline policy from the cache of the server; healing took the secret key and the status from the cache as well. A server whose cache had not heard of an edit made through another server sent the account from before the edit under the revision of the edit, and a peer kept that. replicationItem now sends a create or an edit as the account stored at that time: the whole record, read once, with its revision, as it does for a group. serviceAccountSnapshot builds it from the record and the token in it; IAM export uses the same function, and sync, heal and the update handler only name the account. The wire types and the way a peer applies an item do not change: a peer on 20261109 or 20261026 takes such an edit as it takes a healed account, which also means that it keeps the inline policy on an edit that names none. A site that is not upgraded still sends its edits as before. The hook reads what it sends before it takes the lock of the replication configuration: loading an identity takes that lock for reading, and a read lock asked for again behind a waiting writer is never granted. An account whose stored token cannot be read is an error, as it was for sync and heal, and is not taken for deleted. A new service account of a user that was deleted and created again now reaches the peers without waiting for healing: the create carries the claims of the token, among them the parent revocation a peer asks for. An edit gives every site the whole account of the site that took it. Accounts that already differ between sites under one revision are not repaired by this change, only by their next edit. Signed-off-by: Feng Ruohang Co-Authored-By: Claude Fable 5.2 --- CHANGELOG.md | 28 +- cmd/admin-handlers-users.go | 48 +- cmd/iam-replication-protocol.go | 37 +- cmd/iam-svcacct-replication-snapshot_test.go | 482 +++++++++++++++++++ cmd/iam.go | 81 ++-- cmd/site-replication.go | 99 +--- 6 files changed, 616 insertions(+), 159 deletions(-) create mode 100644 cmd/iam-svcacct-replication-snapshot_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 12c5c73e0..f27bf99ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,10 +25,30 @@ In the Console, clearing the policy editor of an access key no longer removes the policy; enter `{}` to remove it. The fix does not bring back a policy that was removed, and a site of a replicated deployment that does not run the - fix still removes the policy when such an edit reaches it, so sites can - differ. Upgrade every site, then review on each site the accounts that - `svcacct info` reports with `Policy: implied`, and set the policy again on - those that were meant to be restricted. + fix still removes the policy on such an edit that it takes itself or receives + from another site that does not run it, so sites can differ. Upgrade every + site, then review on each site the accounts that `svcacct info` reports with + `Policy: implied`, and set the policy again on those that were meant to be + restricted. +- Site replication sends a service account or access key as the record its + site has stored, whole: for a create, for every edit, at the first sync of a + new site and when healing. IAM export writes that record too. Until now an + edit was sent as the fields it changed. A peer that an earlier edit had not + reached, or reached late, kept the old secret key, status or inline policy + under the revision of the later edit, and healing took the sites for equal: + a key that was rotated, disabled or restricted on one site stayed as it was + on the other. Sync, heal and export took the claims and the inline policy, and + heal the secret key and status as well, from the cache of the server that ran + them, which can predate an edit made through another server of the site, and + sent them under the revision of the stored record. A service account created + for a user that had been deleted and created again did not reach the peers + before healing. Peers on 20261109 and 20261026 apply the new items as they + apply a healed account, and an edit that names no policy no longer removes the + inline policy on such a peer; a site that is not upgraded still sends its own + edits the old way. An edit now gives every site the whole account of the site + that took it, so accounts that already differ between sites are not repaired + by the upgrade, but by the next edit: after upgrading every site, review such + accounts and edit each one at a site that has it right. ## RELEASE.2026-11-09T00-00-00Z diff --git a/cmd/admin-handlers-users.go b/cmd/admin-handlers-users.go index db9764359..bfd859ddd 100644 --- a/cmd/admin-handlers-users.go +++ b/cmd/admin-handlers-users.go @@ -914,21 +914,13 @@ func (a adminAPIHandlers) UpdateServiceAccount(w http.ResponseWriter, r *http.Re } // Call site replication hook - non-root user accounts are replicated. + // The hook sends the account as it is stored after this edit, not the + // fields the request changed (see replicationItem). if svcAccount.ParentUser != globalActiveCred.AccessKey { replLogIf(ctx, globalSiteReplicationSys.IAMChangeHook(ctx, madmin.SRIAMItem{ - Type: madmin.SRIAMItemSvcAcc, - SvcAccChange: &madmin.SRSvcAccChange{ - Update: &madmin.SRSvcAccUpdate{ - AccessKey: accessKey, - SecretKey: opts.secretKey, - Status: opts.status, - Name: opts.name, - Description: opts.description, - SessionPolicy: madmin.SRSessionPolicy(updateReq.NewPolicy), - Expiration: updateReq.NewExpiration, - }, - }, - UpdatedAt: updatedAt, + Type: madmin.SRIAMItemSvcAcc, + SvcAccChange: &madmin.SRSvcAccChange{Update: &madmin.SRSvcAccUpdate{AccessKey: accessKey}}, + UpdatedAt: updatedAt, })) } @@ -2399,37 +2391,13 @@ func (a adminAPIHandlers) ExportIAM(w http.ResponseWriter, r *http.Request) { // skip site-replication service account. continue } - claims, err := globalIAMSys.GetClaimsForSvcAcc(ctx, acc.Credentials.AccessKey) + // The stored record only: this server's cache may not have + // heard of an edit made through another server. + svcAccts[user], err = serviceAccountSnapshot(acc) if err != nil { writeErrorResponse(ctx, w, exportError(ctx, err, iamFile, ""), r.URL) return } - sa, policy, err := globalIAMSys.GetServiceAccount(ctx, acc.Credentials.AccessKey) - if err != nil { - writeErrorResponse(ctx, w, exportError(ctx, err, iamFile, ""), r.URL) - return - } - - var policyJSON []byte - if policy != nil { - policyJSON, err = json.Marshal(policy) - if err != nil { - writeErrorResponse(ctx, w, exportError(ctx, err, iamFile, ""), r.URL) - return - } - } - svcAccts[user] = madmin.SRSvcAccCreate{ - Parent: acc.Credentials.ParentUser, - AccessKey: user, - SecretKey: acc.Credentials.SecretKey, - Groups: acc.Credentials.Groups, - Claims: claims, - SessionPolicy: policyJSON, - Status: acc.Credentials.Status, - Name: sa.Name, - Description: sa.Description, - Expiration: &sa.Expiration, - } } svcAccData, err := json.Marshal(svcAccts) diff --git a/cmd/iam-replication-protocol.go b/cmd/iam-replication-protocol.go index ec2061e99..fe1814823 100644 --- a/cmd/iam-replication-protocol.go +++ b/cmd/iam-replication-protocol.go @@ -172,14 +172,41 @@ func (c *SiteReplicationSys) replicationItem(ctx context.Context, item madmin.SR key = item.SvcAccChange.Update.AccessKey } if key != "" { - r, err := loadIAMRevision(ctx, globalIAMSys.store, getUserIdentityPath(key, svcUser)) + // A create or an edit goes out as the account stored now: the + // whole record, under its revision, whatever the caller's item + // says. A peer orders items by revision and drops an older one, + // so an edit sent as the fields it changed would lose every + // earlier edit that did not reach the peer, or reaches it late. + // A later write that won before sending goes out in place of + // this one. + stored := make(map[string]UserIdentity, 1) + err := globalIAMSys.store.loadUser(ctx, key, svcUser, stored) + if err != nil && !errors.Is(err, errNoSuchUser) { + return out, err + } + u, ok := stored[key] + if !ok { + // Nothing is sent for an account that is deleted, expired or + // of a revoked parent meanwhile; a deletion replicates itself. + // A stored token that cannot be read is an error, and the + // account is not taken for sent. + var raw UserIdentity + err := globalIAMSys.store.loadIAMConfig(ctx, &raw, getUserIdentityPath(key, svcUser)) + if err == nil && !raw.Deleted && !raw.Credentials.IsExpired() { + _, err = extractJWTClaims(raw) + } + if err != nil && !errors.Is(err, errConfigNotFound) { + return out, err + } + return out, errIAMStaleUpdate + } + snapshot, err := serviceAccountSnapshot(u) if err != nil { return out, err } - if r.Deleted || r.timestamp().After(item.UpdatedAt) { - return out, errIAMStaleUpdate - } - out.RevokedBefore = r.RevokedBefore + out.SvcAccChange = &madmin.SRSvcAccChange{Create: &snapshot} + out.UpdatedAt = u.UpdatedAt + out.RevokedBefore = u.RevokedBefore } } if item.Type == madmin.SRIAMItemGroupInfo && item.GroupInfo != nil && !item.GroupInfo.UpdateReq.IsRemove { diff --git a/cmd/iam-svcacct-replication-snapshot_test.go b/cmd/iam-svcacct-replication-snapshot_test.go new file mode 100644 index 000000000..e8259e7f2 --- /dev/null +++ b/cmd/iam-svcacct-replication-snapshot_test.go @@ -0,0 +1,482 @@ +// Copyright (c) 2026 PGSTY +// SPDX-License-Identifier: AGPL-3.0-or-later + +package cmd + +import ( + "archive/zip" + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "path" + "strings" + "sync" + "testing" + "time" + + "github.com/minio/madmin-go/v3" + "github.com/minio/minio/internal/auth" + "github.com/pgsty/silo-pkg/v3/policy" +) + +// Site replication sends a service account as the record its site has +// stored, whole. A peer that applies the last item it was sent then has +// the account of the sending site, whatever it received or lost before, +// and whatever the sending server holds in its cache. + +const svcAccSnapshotPolicy = `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:GetObject"],"Resource":["arn:aws:s3:::snapshot/*"]}]}` + +// svcAccRecord is what a site has of a service account. +type svcAccRecord struct { + Parent, Secret, Status, Name, Description, Policy string + Expiration, UpdatedAt time.Time +} + +// svcAccPolicyText is a policy document in the one form it is compared in; +// an account without an embedded policy has "". +func svcAccPolicyText(t *testing.T, document []byte) string { + t.Helper() + if len(document) == 0 { + return "" + } + p, err := policy.ParseConfig(bytes.NewReader(document)) + mustIAM(t, err) + text, err := json.Marshal(p) + mustIAM(t, err) + return string(text) +} + +// svcAccStored is the account as a server that starts now loads it. +func svcAccStored(t *testing.T, f *groupRefreshFixture, key string) svcAccRecord { + t.Helper() + u, ok := f.stored(t, key) + if !ok { + t.Fatalf("service account %s is not stored", key) + } + cr := u.Credentials + claims, err := getClaimsFromTokenWithSecret(cr.SessionToken, cr.SecretKey) + mustIAM(t, err) + rec := svcAccRecord{Parent: cr.ParentUser, Secret: cr.SecretKey, Status: cr.Status, Name: cr.Name, Description: cr.Description, Expiration: cr.Expiration, UpdatedAt: u.UpdatedAt} + if sp, ok := claims.Lookup(policy.SessionPolicyName); ok && claims.MapClaims[iamPolicyClaimNameSA()] == embeddedPolicyType { + document, err := base64.StdEncoding.DecodeString(sp) + mustIAM(t, err) + rec.Policy = svcAccPolicyText(t, document) + } + return rec +} + +// svcAccSite is a site that replicates to one peer, which records the IAM +// items it is sent. +type svcAccSite struct { + *groupRefreshFixture + mu sync.Mutex + sent []iamReplicationItem +} + +func newSvcAccSite(t *testing.T) *svcAccSite { + t.Helper() + s := &svcAccSite{groupRefreshFixture: newGroupRefreshFixture(t)} + globalObjLayerMutex.Lock() + globalObjectAPI = s.obj + globalObjLayerMutex.Unlock() + + peer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPut && strings.HasSuffix(r.URL.Path, iamRevisionPeerPath) { + var batch iamRevisionBatch + if err := json.NewDecoder(r.Body).Decode(&batch); err != nil { + t.Error(err) + } + s.mu.Lock() + s.sent = append(s.sent, batch.Items...) + s.mu.Unlock() + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(iamRevisionResponse{iamRevisionStatus: iamRevisionStatus{Version: iamRevisionProtocol, Node: "node", Instance: "boot", Digest: "fixture"}}) + })) + globalSiteReplicatorCred.Set("snapshot-site-signing-key") + globalSiteReplicationSys.Lock() + globalSiteReplicationSys.enabled = true + globalSiteReplicationSys.state = srState{ServiceAccountAccessKey: s.parent.AccessKey, Peers: s.peers(peer.URL)} + globalSiteReplicationSys.Unlock() + t.Cleanup(func() { + s.leave() + peer.Close() + }) + return s +} + +func (s *svcAccSite) peers(endpoint string) map[string]madmin.PeerInfo { + return map[string]madmin.PeerInfo{ + globalDeploymentID(): {Name: "local", DeploymentID: globalDeploymentID()}, + "remote": {Name: "remote", DeploymentID: "remote", Endpoint: endpoint}, + } +} + +// leave ends the replication of this process, for the site that comes next. +func (s *svcAccSite) leave() { + globalSiteReplicationSys.Lock() + globalSiteReplicationSys.enabled = false + globalSiteReplicationSys.state = srState{} + globalSiteReplicationSys.Unlock() + globalSiteReplicatorCred.Set("") +} + +// items are the creates and edits of account key the peer was sent, in the +// order of sending. +func (s *svcAccSite) items(key string) []iamReplicationItem { + s.mu.Lock() + defer s.mu.Unlock() + var items []iamReplicationItem + for _, item := range s.sent { + c := item.SvcAccChange + if c != nil && ((c.Create != nil && c.Create.AccessKey == key) || (c.Update != nil && c.Update.AccessKey == key)) { + items = append(items, item) + } + } + return items +} + +// create and edit are an administrator's requests to this site. +func (s *svcAccSite) create(t *testing.T, key string) { + t.Helper() + rec := iamAdminCall(t, globalActiveCred, http.MethodPut, "/minio/admin/v3/add-service-account", + madmin.AddServiceAccountReq{TargetUser: s.parent.AccessKey, AccessKey: key, SecretKey: "snapshot-original-secret"}, nil, adminAPIHandlers{}.AddServiceAccount) + if rec.Code != http.StatusOK { + t.Fatalf("add-service-account: %d %s", rec.Code, rec.Body.String()) + } +} + +func (s *svcAccSite) edit(t *testing.T, key string, req madmin.UpdateServiceAccountReq) { + t.Helper() + rec := iamAdminCall(t, globalActiveCred, http.MethodPost, "/minio/admin/v3/update-service-account?accessKey="+key, + req, map[string]string{"accessKey": key}, adminAPIHandlers{}.UpdateServiceAccount) + if rec.Code != http.StatusNoContent { + t.Fatalf("update-service-account: %d %s", rec.Code, rec.Body.String()) + } +} + +// svcAccPeer is the site that applies items, in the order given, and what +// it then has of account key. It has the parent user, as a peer does. +func svcAccPeer(t *testing.T, from *svcAccSite, key string, items ...iamReplicationItem) svcAccRecord { + t.Helper() + from.leave() + peer := newGroupRefreshFixture(t) + for i, item := range items { + // As the peer reads it off the wire. + raw, err := json.Marshal(item) + mustIAM(t, err) + var received iamReplicationItem + mustIAM(t, json.Unmarshal(raw, &received)) + if err := applyIAMReplicationItem(peer.ctx, received); err != nil { + t.Fatalf("the peer refused item %d: %v", i, err) + } + } + return svcAccStored(t, peer, key) +} + +func wantSvcAccRecord(t *testing.T, where string, got, want svcAccRecord) { + t.Helper() + if got != want { + t.Errorf("%s:\n got %+v\nwant %+v", where, got, want) + } +} + +// An edit of a service account reaches a peer whatever became of the edits +// before it: one that arrives late, or never, is not lost. +func TestSiteReplicationServiceAccountEdits(t *testing.T) { + const key = "snapshot-edits" + for _, tc := range []struct { + name string + edits []madmin.UpdateServiceAccountReq + took func(svcAccRecord) bool // control: what the site has after them + }{ + {"policy-secret-description", []madmin.UpdateServiceAccountReq{ + {NewPolicy: json.RawMessage(svcAccSnapshotPolicy)}, + {NewSecretKey: "snapshot-rotated-secret"}, + {NewDescription: "described"}, + }, func(r svcAccRecord) bool { + return r.Policy != "" && r.Secret == "snapshot-rotated-secret" && r.Description == "described" + }}, + {"secret-disable", []madmin.UpdateServiceAccountReq{ + {NewPolicy: json.RawMessage(svcAccSnapshotPolicy), NewSecretKey: "snapshot-rotated-secret"}, + {NewStatus: string(madmin.AccountDisabled)}, + }, func(r svcAccRecord) bool { + return r.Policy != "" && r.Secret == "snapshot-rotated-secret" && r.Status == auth.AccountOff + }}, + // The last edit removes the embedded policy: the peer gets an + // account without one, and the description set before. + {"policy-removed", []madmin.UpdateServiceAccountReq{ + {NewPolicy: json.RawMessage(svcAccSnapshotPolicy)}, + {NewDescription: "described"}, + {NewPolicy: json.RawMessage(`{"Statement":[]}`)}, + }, func(r svcAccRecord) bool { return r.Policy == "" && r.Description == "described" }}, + // Policy documents an edit ignores: one with a version and no + // statement, one with statements and no version. The site keeps the + // policy it has, and so does the peer. + {"policy-ignored", []madmin.UpdateServiceAccountReq{ + {NewPolicy: json.RawMessage(svcAccSnapshotPolicy)}, + {NewPolicy: json.RawMessage(`{"Version":"2012-10-17","Statement":[]}`), NewName: "named"}, + {NewPolicy: json.RawMessage(`{"Statement":[{"Effect":"Allow","Action":["s3:PutObject"],"Resource":["arn:aws:s3:::ignored/*"]}]}`), NewDescription: "described"}, + }, func(r svcAccRecord) bool { + return r.Policy == svcAccPolicyText(t, []byte(svcAccSnapshotPolicy)) && r.Name == "named" && r.Description == "described" + }}, + } { + for _, delivery := range []string{"late", "lost"} { + t.Run(tc.name+"/"+delivery, func(t *testing.T) { + site := newSvcAccSite(t) + site.create(t, key) + for _, edit := range tc.edits { + site.edit(t, key, edit) + } + sent := site.items(key) + if len(sent) != 1+len(tc.edits) { + t.Fatalf("the site sent %d items for the account, want one for the create and each edit", len(sent)) + } + want := svcAccStored(t, site.groupRefreshFixture, key) + if !tc.took(want) { + t.Fatalf("control: the site did not apply the edits: %+v", want) + } + // The peer has the account and gets the last edit. The edits + // before it were lost, or reach it after the last. + last := len(sent) - 1 + deliver := []iamReplicationItem{sent[0], sent[last]} + if delivery == "late" { + deliver = append(deliver, sent[1:last]...) + } + wantSvcAccRecord(t, "peer", svcAccPeer(t, site, key, deliver...), want) + }) + } + } + + // Two requests edit the account through this site at once, and the + // first comes to replicate when the second is stored. + t.Run("concurrent", func(t *testing.T) { + site := newSvcAccSite(t) + site.create(t, key) + sp, err := policy.ParseConfig(strings.NewReader(svcAccSnapshotPolicy)) + mustIAM(t, err) + first, err := site.sys.UpdateServiceAccount(site.ctx, key, updateServiceAccountOpts{sessionPolicy: sp}) + mustIAM(t, err) + second, err := site.sys.UpdateServiceAccount(site.ctx, key, updateServiceAccountOpts{description: "described"}) + mustIAM(t, err) + for _, at := range []time.Time{first, second} { + mustIAM(t, globalSiteReplicationSys.IAMChangeHook(site.ctx, madmin.SRIAMItem{ + Type: madmin.SRIAMItemSvcAcc, + SvcAccChange: &madmin.SRSvcAccChange{Update: &madmin.SRSvcAccUpdate{AccessKey: key}}, + UpdatedAt: at, + })) + } + want := svcAccStored(t, site.groupRefreshFixture, key) + if want.Policy == "" || want.Description != "described" { + t.Fatalf("control: the site did not apply the edits: %+v", want) + } + wantSvcAccRecord(t, "peer", svcAccPeer(t, site, key, site.items(key)...), want) + }) +} + +// Another server of the site restricted, rotated and disabled the account, +// and this server's cache has not heard of it. What this server sends, and +// exports, is the account that is stored. +func TestSiteReplicationServiceAccountStaleCache(t *testing.T) { + const key = "snapshot-stale" + // prepare returns the site, the stored account and what it sent for the + // create. + prepare := func(t *testing.T) (*svcAccSite, svcAccRecord, iamReplicationItem) { + t.Helper() + site := newSvcAccSite(t) + site.create(t, key) + created := site.items(key) + if len(created) != 1 { + t.Fatalf("the site sent %d items for the create", len(created)) + } + sp, err := policy.ParseConfig(strings.NewReader(svcAccSnapshotPolicy)) + mustIAM(t, err) + _, err = site.sibling(t).UpdateServiceAccount(site.ctx, key, updateServiceAccountOpts{ + sessionPolicy: sp, secretKey: "snapshot-rotated-secret", status: auth.AccountOff, description: "restricted", + }) + mustIAM(t, err) + if cached, _ := site.sys.store.GetUser(key); cached.Credentials.SecretKey != "snapshot-original-secret" || cached.Credentials.Status != auth.AccountOn { + t.Fatal("control: this server's cache must not have heard of the edit") + } + want := svcAccStored(t, site.groupRefreshFixture, key) + if want.Policy == "" || want.Secret != "snapshot-rotated-secret" || want.Status != auth.AccountOff { + t.Fatalf("control: the edit is not stored: %+v", want) + } + return site, want, created[0] + } + + t.Run("heal", func(t *testing.T) { + site, want, created := prepare(t) + local := madmin.UserInfo{Status: madmin.AccountStatus(want.Status), UpdatedAt: want.UpdatedAt} + remote := madmin.UserInfo{Status: madmin.AccountStatus(auth.AccountOn), UpdatedAt: created.UpdatedAt} + info := srStatusInfo{Sites: globalSiteReplicationSys.state.Peers, UserStats: map[string]map[string]srUserStatsSummary{key: { + globalDeploymentID(): {userInfo: srUserInfo{UserInfo: local}}, + "remote": {SRUserStatsSummary: madmin.SRUserStatsSummary{UserInfoMismatch: true}, userInfo: srUserInfo{UserInfo: remote}}, + }}} + mustIAM(t, globalSiteReplicationSys.healUsers(site.ctx, site.obj, key, info)) + sent := site.items(key) + if len(sent) != 2 { + t.Fatalf("heal sent %d items for the account, want 1", len(sent)-1) + } + wantSvcAccRecord(t, "healed peer", svcAccPeer(t, site, key, sent...), want) + }) + + t.Run("sync", func(t *testing.T) { + site, want, _ := prepare(t) + mustIAM(t, globalSiteReplicationSys.syncToAllPeers(site.ctx, madmin.SRAddOptions{})) + sent := site.items(key) + if len(sent) != 2 { + t.Fatalf("sync sent %d items for the account, want 1", len(sent)-1) + } + wantSvcAccRecord(t, "new peer", svcAccPeer(t, site, key, sent[1]), want) + }) + + t.Run("export", func(t *testing.T) { + _, want, _ := prepare(t) + rec := iamAdminCall(t, globalActiveCred, http.MethodGet, "/minio/admin/v3/export-iam", nil, nil, adminAPIHandlers{}.ExportIAM) + if rec.Code != http.StatusOK { + t.Fatalf("export-iam: %d %s", rec.Code, rec.Body.String()) + } + archive, err := zip.NewReader(bytes.NewReader(rec.Body.Bytes()), int64(rec.Body.Len())) + mustIAM(t, err) + file, err := archive.Open(path.Join(iamAssetsDir, allSvcAcctsFile)) + mustIAM(t, err) + raw, err := io.ReadAll(file) + mustIAM(t, err) + var accounts map[string]madmin.SRSvcAccCreate + mustIAM(t, json.Unmarshal(raw, &accounts)) + a, ok := accounts[key] + if !ok || a.Expiration == nil { + t.Fatalf("the export has no account %s with an expiration (present=%v)", key, ok) + } + got := svcAccRecord{Parent: a.Parent, Secret: a.SecretKey, Status: a.Status, Name: a.Name, Description: a.Description, Policy: svcAccPolicyText(t, a.SessionPolicy), Expiration: *a.Expiration} + want.UpdatedAt = time.Time{} // an export has no revisions + wantSvcAccRecord(t, "export", got, want) + if a.Claims[iamPolicyClaimNameSA()] != embeddedPolicyType { + t.Errorf("exported claims say %v, want %s", a.Claims[iamPolicyClaimNameSA()], embeddedPolicyType) + } + }) +} + +// The parent was deleted and created again on both sites. The token of a +// service account created afterwards names that revocation, and a peer +// refuses a credential of the parent that does not: the create must carry +// the claims of the token. +func TestSiteReplicationServiceAccountOfRecreatedParent(t *testing.T) { + const key = "snapshot-reparent" + site := newSvcAccSite(t) + mustIAM(t, site.sys.DeleteUser(site.ctx, site.parent.AccessKey, false)) + iamAdminUser(site.ctx, t, site.sys, site.parent.AccessKey, "refresh-parent") + revoked, err := loadIAMRevision(site.ctx, site.sys.store, getUserIdentityPath(site.parent.AccessKey, regUser)) + mustIAM(t, err) + if revoked.RevokedBefore.IsZero() { + t.Fatal("control: the parent must have a revocation boundary") + } + site.create(t, key) + sent := site.items(key) + if len(sent) != 1 { + t.Fatalf("the site sent %d items for the create", len(sent)) + } + want := svcAccStored(t, site.groupRefreshFixture, key) + + // The peer's parent is newer than the deletion, which leaves it live + // with the boundary: a parent that was deleted and created again. + site.leave() + peer := newGroupRefreshFixture(t) + mustIAM(t, peer.sys.DeleteUser(withIAMReplicationTime(peer.ctx, revoked.RevokedBefore), peer.parent.AccessKey, false)) + if r, err := loadIAMRevision(peer.ctx, peer.sys.store, getUserIdentityPath(peer.parent.AccessKey, regUser)); err != nil || r.Deleted || !r.RevokedBefore.Equal(revoked.RevokedBefore) { + t.Fatalf("control: the peer's parent must be live with the boundary (err=%v deleted=%v)", err, r.Deleted) + } + mustIAM(t, applyIAMReplicationItem(peer.ctx, sent[0])) + if _, ok := peer.stored(t, key); !ok { + t.Fatal("the peer dropped the new service account") + } + wantSvcAccRecord(t, "peer", svcAccStored(t, peer, key), want) +} + +// An account that is deleted is not sent, and that is not an error. An +// account whose stored token cannot be read is not taken for deleted: the +// site signing key is not available here, and no token is read without it. +func TestSiteReplicationServiceAccountUnreadable(t *testing.T) { + const key = "snapshot-unreadable" + site := newSvcAccSite(t) + site.create(t, key) + item := madmin.SRIAMItem{Type: madmin.SRIAMItemSvcAcc, SvcAccChange: &madmin.SRSvcAccChange{Update: &madmin.SRSvcAccUpdate{AccessKey: key}}} + + globalSiteReplicatorCred.Set("") + if _, err := globalSiteReplicationSys.replicationItem(site.ctx, item); err == nil || errors.Is(err, errIAMStaleUpdate) { + t.Errorf("an account whose token cannot be read must be an error, got %v", err) + } + if err := globalSiteReplicationSys.IAMChangeHook(site.ctx, item); err == nil { + t.Error("the hook reported an account as sent that it could not read") + } + globalSiteReplicatorCred.Set("snapshot-site-signing-key") + + mustIAM(t, site.sys.DeleteServiceAccount(site.ctx, key, false)) + if _, err := globalSiteReplicationSys.replicationItem(site.ctx, item); !errors.Is(err, errIAMStaleUpdate) { + t.Errorf("a deleted account must not be sent, got %v", err) + } + if sent := site.items(key); len(sent) != 1 { + t.Errorf("the site sent %d items for the account, want only the create", len(sent)) + } +} + +// svcAccPausedStore holds every load of a user until proceed is closed. +type svcAccPausedStore struct { + IAMStorageAPI + once sync.Once + loading, proceed chan struct{} +} + +func (s *svcAccPausedStore) loadUser(ctx context.Context, user string, userType IAMUserType, m map[string]UserIdentity) error { + s.once.Do(func() { close(s.loading) }) + <-s.proceed + return s.IAMStorageAPI.loadUser(ctx, user, userType, m) +} + +// The hook reads the account from storage without the lock of the +// replication configuration. Loading an identity takes that lock for +// reading; if the hook held it too, a change of the configuration that +// arrives in between would wait for the hook, and the hook for it. +func TestSiteReplicationHookReadsStorageUnlocked(t *testing.T) { + const key = "snapshot-unlocked" + site := newSvcAccSite(t) + site.create(t, key) + paused := &svcAccPausedStore{IAMStorageAPI: site.sys.store.IAMStorageAPI, loading: make(chan struct{}), proceed: make(chan struct{})} + site.sys.store.IAMStorageAPI = paused + + sent := make(chan error, 1) + go func() { + sent <- globalSiteReplicationSys.IAMChangeHook(site.ctx, madmin.SRIAMItem{ + Type: madmin.SRIAMItemSvcAcc, + SvcAccChange: &madmin.SRSvcAccChange{Update: &madmin.SRSvcAccUpdate{AccessKey: key}}, + }) + }() + select { + case <-paused.loading: + case err := <-sent: + t.Fatalf("the hook did not read the account from storage (err=%v)", err) + } + // A writer that had to wait here would never get the lock, nor the + // hook its next read lock behind that writer: try, do not wait. + if globalSiteReplicationSys.TryLock() { + globalSiteReplicationSys.Unlock() //nolint:staticcheck // only the lock is of interest + } else { + t.Error("the hook holds the lock of the replication configuration while it reads storage") + } + close(paused.proceed) + select { + case err := <-sent: + mustIAM(t, err) + case <-time.After(10 * time.Second): + t.Fatal("the hook did not return") + } + if got := site.items(key); len(got) != 2 { + t.Errorf("the site sent %d items for the account, want the create and the edit", len(got)) + } +} diff --git a/cmd/iam.go b/cmd/iam.go index 6f8ffad18..a89415c27 100644 --- a/cmd/iam.go +++ b/cmd/iam.go @@ -1311,22 +1311,62 @@ func (sys *IAMSys) getServiceAccount(ctx context.Context, accessKey string) (Use return UserIdentity{}, nil, errNoSuchServiceAccount } - var embeddedPolicy *policy.Policy + embeddedPolicy, err := embeddedServiceAccountPolicy(jwtClaims) + if err != nil { + return UserIdentity{}, nil, err + } + return sa, embeddedPolicy, nil +} +// embeddedServiceAccountPolicy returns the policy embedded in the token of a +// service account, or nil when the account has the policies of its parent. +func embeddedServiceAccountPolicy(jwtClaims *jwt.MapClaims) (*policy.Policy, error) { pt, ptok := jwtClaims.Lookup(iamPolicyClaimNameSA()) sp, spok := jwtClaims.Lookup(policy.SessionPolicyName) - if ptok && spok && pt == embeddedPolicyType { - policyBytes, err := base64.StdEncoding.DecodeString(sp) - if err != nil { - return UserIdentity{}, nil, err - } - embeddedPolicy, err = policy.ParseConfig(bytes.NewReader(policyBytes)) - if err != nil { - return UserIdentity{}, nil, err + if !ptok || !spok || pt != embeddedPolicyType { + return nil, nil + } + policyBytes, err := base64.StdEncoding.DecodeString(sp) + if err != nil { + return nil, err + } + return policy.ParseConfig(bytes.NewReader(policyBytes)) +} + +// serviceAccountSnapshot returns service account u, a record loaded from +// storage, as the create that replication, the first sync, heal and export +// carry. Its claims and its embedded policy are read from the token of that +// record, so that every part of the snapshot is of one revision: a server's +// cache may hold the account as it was before an edit made through another +// server. +func serviceAccountSnapshot(u UserIdentity) (madmin.SRSvcAccCreate, error) { + jwtClaims, err := extractJWTClaims(u) + if err != nil { + return madmin.SRSvcAccCreate{}, err + } + embeddedPolicy, err := embeddedServiceAccountPolicy(jwtClaims) + if err != nil { + return madmin.SRSvcAccCreate{}, err + } + var policyJSON []byte + if embeddedPolicy != nil { + if policyJSON, err = json.Marshal(embeddedPolicy); err != nil { + return madmin.SRSvcAccCreate{}, err } } - - return sa, embeddedPolicy, nil + cr := u.Credentials + return madmin.SRSvcAccCreate{ + Parent: cr.ParentUser, + AccessKey: cr.AccessKey, + SecretKey: cr.SecretKey, + Groups: cr.Groups, + Claims: jwtClaims.Map(), + SessionPolicy: policyJSON, + Status: cr.Status, + Name: cr.Name, + Description: cr.Description, + Expiration: &cr.Expiration, + }, nil } // GetTemporaryAccount - wrapper method to get information about a temporary account @@ -1398,25 +1438,6 @@ func (sys *IAMSys) getAccountWithClaims(ctx context.Context, accessKey string) ( return acc, jwtClaims, nil } -// GetClaimsForSvcAcc - gets the claims associated with the service account. -func (sys *IAMSys) GetClaimsForSvcAcc(ctx context.Context, accessKey string) (map[string]any, error) { - if !sys.Initialized() { - return nil, errServerNotInitialized - } - - sa, ok := sys.store.GetUser(accessKey) - if !ok || !sa.Credentials.IsServiceAccount() { - return nil, errNoSuchServiceAccount - } - - jwtClaims, err := extractJWTClaims(sa) - if err != nil { - return nil, err - } - - return jwtClaims.Map(), nil -} - // DeleteServiceAccount - delete a service account func (sys *IAMSys) DeleteServiceAccount(ctx context.Context, accessKey string, notifyPeers bool) error { if !sys.Initialized() { diff --git a/cmd/site-replication.go b/cmd/site-replication.go index 6c82fa52b..0d0765d42 100644 --- a/cmd/site-replication.go +++ b/cmd/site-replication.go @@ -1256,9 +1256,10 @@ func (c *SiteReplicationSys) IAMChangeHook(ctx context.Context, item madmin.SRIA // The IAM item has already been applied to the local cluster at this // point, and only needs to be updated on all remote peer clusters. - c.RLock() - defer c.RUnlock() - if !c.enabled { + // What is sent is read from storage before the lock is taken: loading + // an identity asks whether site replication is enabled, and a read lock + // asked for a second time behind a waiting writer is never granted. + if !c.isEnabled() { return nil } @@ -1284,6 +1285,12 @@ func (c *SiteReplicationSys) IAMChangeHook(ctx context.Context, item madmin.SRIA if err != nil { return err } + + c.RLock() + defer c.RUnlock() + if !c.enabled { + return nil + } cerr := c.concDo(nil, func(d string, p madmin.PeerInfo) error { admClient, err := c.getAdminClient(ctx, d) if err != nil { @@ -2365,41 +2372,11 @@ func (c *SiteReplicationSys) syncToAllPeers(ctx context.Context, addOpts madmin. continue } - claims, err := globalIAMSys.GetClaimsForSvcAcc(ctx, acc.Credentials.AccessKey) - if err != nil { - return errSRBackendIssue(err) - } - - _, policy, err := globalIAMSys.GetServiceAccount(ctx, acc.Credentials.AccessKey) - if err != nil { - return errSRBackendIssue(err) - } - - var policyJSON []byte - if policy != nil { - policyJSON, err = json.Marshal(policy) - if err != nil { - return wrapSRErr(err) - } - } - + // The hook sends the account as it is stored (see replicationItem). err = c.IAMChangeHook(ctx, madmin.SRIAMItem{ - Type: madmin.SRIAMItemSvcAcc, - SvcAccChange: &madmin.SRSvcAccChange{ - Create: &madmin.SRSvcAccCreate{ - Parent: acc.Credentials.ParentUser, - AccessKey: user, - SecretKey: acc.Credentials.SecretKey, - Groups: acc.Credentials.Groups, - Claims: claims, - SessionPolicy: policyJSON, - Status: acc.Credentials.Status, - Name: acc.Credentials.Name, - Description: acc.Credentials.Description, - Expiration: &acc.Credentials.Expiration, - }, - }, - UpdatedAt: acc.UpdatedAt, + Type: madmin.SRIAMItemSvcAcc, + SvcAccChange: &madmin.SRSvcAccChange{Create: &madmin.SRSvcAccCreate{AccessKey: user}}, + UpdatedAt: acc.UpdatedAt, }) if err != nil { return errSRIAMError(err) @@ -5485,50 +5462,12 @@ func (c *SiteReplicationSys) healUsers(ctx context.Context, objAPI ObjectLayer, } creds := u.Credentials if creds.IsServiceAccount() { - claims, err := globalIAMSys.GetClaimsForSvcAcc(ctx, creds.AccessKey) - if err != nil { - replLogOnceIf(ctx, - fmt.Errorf("Unable to heal service account from peer site %s -> %s : %w", latestPeerName, peerName, err), - fmt.Sprintf("heal-user-%s", user)) - continue - } - - _, policy, err := globalIAMSys.GetServiceAccount(ctx, creds.AccessKey) - if err != nil { - replLogOnceIf(ctx, - fmt.Errorf("Unable to heal service account from peer site %s -> %s : %w", latestPeerName, peerName, err), - fmt.Sprintf("heal-user-%s", user)) - continue - } - - var policyJSON []byte - if policy != nil { - policyJSON, err = json.Marshal(policy) - if err != nil { - replLogOnceIf(ctx, - fmt.Errorf("Unable to heal service account from peer site %s -> %s : %w", latestPeerName, peerName, err), - fmt.Sprintf("heal-user-%s", user)) - continue - } - } - + // The hook sends the account as it is stored (see + // replicationItem), not as this server has it cached. if err := c.IAMChangeHook(ctx, madmin.SRIAMItem{ - Type: madmin.SRIAMItemSvcAcc, - SvcAccChange: &madmin.SRSvcAccChange{ - Create: &madmin.SRSvcAccCreate{ - Parent: creds.ParentUser, - AccessKey: creds.AccessKey, - SecretKey: creds.SecretKey, - Groups: creds.Groups, - Claims: claims, - SessionPolicy: policyJSON, - Status: creds.Status, - Name: creds.Name, - Description: creds.Description, - Expiration: &creds.Expiration, - }, - }, - UpdatedAt: lastUpdate, + Type: madmin.SRIAMItemSvcAcc, + SvcAccChange: &madmin.SRSvcAccChange{Create: &madmin.SRSvcAccCreate{AccessKey: creds.AccessKey}}, + UpdatedAt: lastUpdate, }); err != nil { replLogOnceIf(ctx, fmt.Errorf("Unable to heal service account from peer site %s -> %s : %w", latestPeerName, peerName, err),