mirror of
https://github.com/pgsty/minio.git
synced 2026-11-11 18:15:29 +03:00
Compare commits
68 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8587e061bb | |||
| 293ab78b4c | |||
| 67a90dfef4 | |||
| 62b8784428 | |||
| 5dc4bd6a1d | |||
| cf486df661 | |||
| 1d714834c5 | |||
| 14ecb222e7 | |||
| e51bff35a3 | |||
| 20b3bfb6e7 | |||
| 322cd8d2c1 | |||
| c3340ec7f5 | |||
| 7c91dc17b2 | |||
| 2f93e0c6b3 | |||
| 65acda0114 | |||
| c6949a38c2 | |||
| 80dd3e3ce1 | |||
| 89028732ab | |||
| 55bd906d30 | |||
| 296c9710b1 | |||
| f3b42b0eb8 | |||
| da8d8a54b1 | |||
| 2b62df876a | |||
| 518915d241 | |||
| ce5d191077 | |||
| b404f78764 | |||
| d138996983 | |||
| d52ba0290a | |||
| 8f3657dc52 | |||
| 7efdedd5ba | |||
| a18293d0c3 | |||
| 032ac49061 | |||
| 244de7ecc1 | |||
| 63650a31a1 | |||
| 36c8d1683a | |||
| c11bfda8f4 | |||
| 6a65b43076 | |||
| 3269d2214e | |||
| b3e69bca2f | |||
| d998292387 | |||
| 6ef3a52fc3 | |||
| e21b1c3a71 | |||
| 86dc6ee2e2 | |||
| 0a238589a4 | |||
| 1f731549e4 | |||
| 368c52f4dc | |||
| f0a4d76ef8 | |||
| aa2d79daf2 | |||
| 7e9ee6befc | |||
| 47275042f6 | |||
| d66b3889cd | |||
| 099b7ddf37 | |||
| c054569b9f | |||
| 1f579afeed | |||
| b14dc454fb | |||
| bb31c26183 | |||
| 07ce95dfda | |||
| 7eed95bc8e | |||
| 70914759e5 | |||
| a0de92fcbe | |||
| af0afd2321 | |||
| 633c9bfe09 | |||
| 18e09ca219 | |||
| a6571a53e9 | |||
| a2ff3c2ecd | |||
| 67bc32f401 | |||
| 3c4a6296f9 | |||
| afa35d16e9 |
@@ -78,7 +78,7 @@ jobs:
|
||||
race-s3select:
|
||||
name: Race, S3 Select
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
@@ -105,7 +105,7 @@ jobs:
|
||||
- name: Run lifecycle expiry and native restore tests under race detector
|
||||
env:
|
||||
MINIO_API_REQUESTS_MAX: "10000"
|
||||
run: go test -race -tags kqueue,dev ./cmd -run '^Test(PoolsLifecycle|PoolsMigrationLifecycle|ExpireRestored|NativeRestore)' -count=1 -timeout=5m
|
||||
run: go test -race -tags kqueue,dev ./cmd -run '^Test(PoolsLifecycle|PoolsMigrationLifecycle|ExpireRestored|NativeRestore)' -count=1 -timeout=10m
|
||||
|
||||
- name: Run multipart listing, cancellation and cleanup tests under race detector
|
||||
env:
|
||||
@@ -127,7 +127,7 @@ jobs:
|
||||
- name: Run Object Lock authorization and overwrite tests under race detector
|
||||
env:
|
||||
MINIO_API_REQUESTS_MAX: "10000"
|
||||
run: go test -race -tags kqueue,dev ./cmd -run '^Test(APIPutObjectRetention|APIVersionAddressedWrite|PutObjectRetention|DirectoryObjectLock|ObjectLockOverwrite|APICopyObjectPreservesVersionAfterSuspension|MultipartNullObjectLock|PoolsObjectLockOverwrite|MultipartVersioningTransitions|PlanReview|RecoveredMultipartUpload)' -count=1 -timeout=5m
|
||||
run: go test -race -tags kqueue,dev ./cmd -run '^Test(APIPutObjectRetention|APIVersionAddressedWrite|PutObjectRetention|DirectoryObjectLock|ObjectLockOverwrite|APICopyObjectPreservesVersionAfterSuspension|MultipartNullObjectLock|PoolsObjectLockOverwrite|ReplicaDeleteObjectLock|PoolsReplicaDeleteObjectLock|BulkDeleteDirectoryObjectLock|BulkDeleteObjectLock|MultipartVersioningTransitions|PlanReview|RecoveredMultipartUpload)' -count=1 -timeout=5m
|
||||
|
||||
- name: Run maintenance storage timeout and compatibility tests under race detector
|
||||
env:
|
||||
|
||||
@@ -74,7 +74,7 @@ jobs:
|
||||
- name: Gate release on Object Lock authorization and overwrite tests under race detector
|
||||
env:
|
||||
MINIO_API_REQUESTS_MAX: "10000"
|
||||
run: go test -race -tags kqueue,dev ./cmd -run '^Test(APIPutObjectRetention|APIVersionAddressedWrite|PutObjectRetention|DirectoryObjectLock|ObjectLockOverwrite|APICopyObjectPreservesVersionAfterSuspension|MultipartNullObjectLock|PoolsObjectLockOverwrite|MultipartVersioningTransitions|PlanReview|RecoveredMultipartUpload)' -count=1 -timeout=5m
|
||||
run: go test -race -tags kqueue,dev ./cmd -run '^Test(APIPutObjectRetention|APIVersionAddressedWrite|PutObjectRetention|DirectoryObjectLock|ObjectLockOverwrite|APICopyObjectPreservesVersionAfterSuspension|MultipartNullObjectLock|PoolsObjectLockOverwrite|ReplicaDeleteObjectLock|PoolsReplicaDeleteObjectLock|BulkDeleteDirectoryObjectLock|BulkDeleteObjectLock|MultipartVersioningTransitions|PlanReview|RecoveredMultipartUpload)' -count=1 -timeout=5m
|
||||
|
||||
- name: Compute release variables
|
||||
env:
|
||||
|
||||
+224
@@ -1,5 +1,229 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
- 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 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.
|
||||
|
||||
- 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.
|
||||
|
||||
- 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
|
||||
|
||||
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:
|
||||
|
||||
- Decide an ILM expiry under the object lock against the versions of the key
|
||||
in all pools, and refuse one that does not hold for the whole object or whose
|
||||
history or lock protection lacks an authoritative quorum. Decommission and
|
||||
rebalance move such versions instead of dropping them, and an approved
|
||||
version expiry deletes every copy ([#278](https://github.com/pgsty/silo/issues/278)).
|
||||
- Expire the restored copy of a current version instead of the object
|
||||
([#281](https://github.com/pgsty/silo/issues/281)). Keep lifecycle delete-all
|
||||
(`ExpiredObjectAllVersions`, `DelMarkerExpiration`, batch expiration with
|
||||
`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)).
|
||||
- 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
|
||||
([#305](https://github.com/pgsty/silo/issues/305)).
|
||||
- Keep remote tier data that another pool still references after a version
|
||||
expiry or tier cleanup ([#306](https://github.com/pgsty/silo/issues/306),
|
||||
[#308](https://github.com/pgsty/silo/issues/308)). Heal versions whose queued
|
||||
expiry is refused ([#303](https://github.com/pgsty/silo/issues/303)), and read
|
||||
a key's history once in migration prechecks ([#304](https://github.com/pgsty/silo/issues/304)).
|
||||
- Decommission and rebalance: move objects whose copies have two erasure
|
||||
layouts ([#261](https://github.com/pgsty/silo/issues/261)) and ILM-expired
|
||||
current versions ([#265](https://github.com/pgsty/silo/issues/265)), continue
|
||||
with later buckets when one cannot finish and keep cancellation
|
||||
([#307](https://github.com/pgsty/silo/issues/307)), and do not panic when a
|
||||
rebalance stop finds no metadata ([#270](https://github.com/pgsty/silo/issues/270)).
|
||||
- Do not replicate the delete markers and version purges that lifecycle
|
||||
creates, as in S3. [PR #240](https://github.com/pgsty/silo/pull/240) remains
|
||||
open as a proposal.
|
||||
|
||||
Native restore:
|
||||
|
||||
- Restore in two phases, without holding the object write lock during the tier
|
||||
download ([#284](https://github.com/pgsty/silo/issues/284)). Restores with more
|
||||
than one pool no longer hang, stay in progress or change the restored version
|
||||
([#289](https://github.com/pgsty/silo/issues/289)), and a failed ordinary
|
||||
multipart completion stays usable ([#327](https://github.com/pgsty/silo/issues/327)).
|
||||
A failed or canceled restore returns at once and clears its marker in the
|
||||
background, instead of waiting up to a minute for the object lock.
|
||||
|
||||
Locking, conditional writes and storage:
|
||||
|
||||
- Lock every write and delete of a single pool in the object's own erasure set
|
||||
([#298](https://github.com/pgsty/silo/issues/298)). Fix CopyObject waiting on
|
||||
its own lock for normalized aliases ([#299](https://github.com/pgsty/silo/issues/299))
|
||||
and starving its destination while waiting for the source
|
||||
([#300](https://github.com/pgsty/silo/issues/300)); delete the set groups of a
|
||||
bulk delete concurrently ([#301](https://github.com/pgsty/silo/issues/301)).
|
||||
- Evaluate `If-Match` and `If-None-Match` of a multi-pool CreateMultipartUpload
|
||||
against the current object ([#291](https://github.com/pgsty/silo/issues/291),
|
||||
[PR #292](https://github.com/pgsty/silo/pull/292)).
|
||||
- Answer a retryable `503` instead of `NoSuchKey` when a node sees only some
|
||||
drives of an existing object ([#302](https://github.com/pgsty/silo/issues/302)), remove the empty object
|
||||
directory of a failed write ([#285](https://github.com/pgsty/silo/issues/285),
|
||||
[PR #286](https://github.com/pgsty/silo/pull/286)), classify `xl.meta`
|
||||
checksum failures for healing ([#309](https://github.com/pgsty/silo/issues/309)),
|
||||
and keep a heal dry run from deleting data
|
||||
([PR #280](https://github.com/pgsty/silo/pull/280), thanks to
|
||||
[@dihmandrake](https://github.com/dihmandrake)).
|
||||
|
||||
Object Lock, IAM and batch jobs:
|
||||
|
||||
- Keep the target's legal hold and retention on a replicated delete; a source
|
||||
purge that bypassed GOVERNANCE retention is refused by a target copy that
|
||||
still carries it, and the source retries. Check a directory object's lock
|
||||
before a bulk delete removes its null version. S3 `DeleteObjects` checks
|
||||
physical version removals under the mutation lock and merges protection
|
||||
across pools, including concurrent legal-hold and retention updates. These
|
||||
removals in Object Lock buckets use the single-object path sequentially;
|
||||
large version-purge batches can take longer. Marker creation and ordinary
|
||||
buckets retain the bulk path.
|
||||
- Batch jobs enforce the submitter's data permissions throughout execution and
|
||||
leave Object Lock protected versions alone ([#310](https://github.com/pgsty/silo/issues/310),
|
||||
[#311](https://github.com/pgsty/silo/issues/311)). Key rotation keeps other
|
||||
pool copies readable ([#312](https://github.com/pgsty/silo/issues/312)).
|
||||
Transient failures pause work instead of ending it, saved jobs are found
|
||||
after a failed startup scan, and running replication resumes
|
||||
([#313](https://github.com/pgsty/silo/issues/313), [#314](https://github.com/pgsty/silo/issues/314),
|
||||
[#324](https://github.com/pgsty/silo/issues/324)). A completed expiration job
|
||||
stays completed after a restart ([#315](https://github.com/pgsty/silo/issues/315)).
|
||||
Authorization skips deleted policies a mapping still names, refuses a job
|
||||
only for a Deny that needs request context, and survives metadata edits and
|
||||
LDAP refreshes that keep the credential
|
||||
([#318](https://github.com/pgsty/silo/issues/318)–[#320](https://github.com/pgsty/silo/issues/320)).
|
||||
Live metrics keep pause reasons and skipped or refused counts
|
||||
([#325](https://github.com/pgsty/silo/issues/325)), and `make check-gen`
|
||||
leaves the runtime policy snapshot alone ([#326](https://github.com/pgsty/silo/issues/326)).
|
||||
- IAM refuses incomplete policy loads, keeps cached authentication available
|
||||
during lazy loads, and applies edits, LDAP group refreshes and service-account
|
||||
creation to the stored record, so a stale node cannot restore old credentials
|
||||
or resurrect deleted identities ([#316](https://github.com/pgsty/silo/issues/316),
|
||||
[#317](https://github.com/pgsty/silo/issues/317), [#321](https://github.com/pgsty/silo/issues/321)–[#323](https://github.com/pgsty/silo/issues/323)).
|
||||
- Log in LDAP users with long DNs on erasure sets, and root to the embedded
|
||||
Console with LDAP enabled ([#271](https://github.com/pgsty/silo/issues/271),
|
||||
[#272](https://github.com/pgsty/silo/issues/272)). Keep audit and logger
|
||||
webhook targets running on every server after a configuration reload
|
||||
([#259](https://github.com/pgsty/silo/issues/259), reported by
|
||||
[@haf](https://github.com/haf)).
|
||||
|
||||
Components and packaging:
|
||||
|
||||
- Embed the published Console v2.7.0 source `4e91ae506e70`, including its
|
||||
ten-language UI, rebuilt frontend assets, Swagger array-validation fixes,
|
||||
Redoc routing, plain-HTTP folder-size and key-copy actions, usage-worker TLS
|
||||
roots and scan budgets, and malformed-multipart rejection. Select the
|
||||
published mcli 20261109 source `e1d4cc5abfe0` and silo-pkg v3.16.0 directly
|
||||
in the maintained dependency graph.
|
||||
- Adopt go-jose/v4 v4.1.9, cpuid/v2 v2.4.2, YAML v3.0.6 and gjson v1.20.1,
|
||||
plus Console's runtime/server-middleware v0.33.4. Preserve Go 1.27.3, the
|
||||
verified upstream minio-go commit, networking/crypto security floors and
|
||||
terminal-width/NetBSD compatibility selections.
|
||||
- Update NATS credentials to jwt/v2 v2.8.3 and nkeys v0.4.17, including
|
||||
rejection of identity-point and small-order public keys. Use nats-server
|
||||
v2.15.2 for notification integration tests.
|
||||
- Update Google Cloud auth to v0.24.3 and oauth2adapt to v0.3.1 for REST
|
||||
request validation and AWS FIPS endpoint signing fixes.
|
||||
- Bundle verified mcli 20261109 archives in source container builds and use
|
||||
that release by default in the integration-test client installer.
|
||||
- Prevent replication worker metrics from panicking when the replication
|
||||
pool is absent, while retaining historical averages and peak counts.
|
||||
- Give the distroless image the same CA roots as the standard image, and ship
|
||||
mcli's license files in both ([#295](https://github.com/pgsty/silo/pull/295)).
|
||||
- Deploy SILO and mcli `RELEASE.2026-11-09T00-00-00Z` by default from Helm
|
||||
chart 7.0.4; the server image tag was left at 20260916 before
|
||||
([#296](https://github.com/pgsty/silo/issues/296)).
|
||||
|
||||
Upgrade notes:
|
||||
|
||||
- A single pool whose erasure sets do not all use the same hosts (with the
|
||||
automatic layout, 18 or more hosts) needs a drained restart, not a rolling
|
||||
upgrade: old and new nodes lock deletes and copies in different sets and do
|
||||
not exclude each other. Stop every old process before a new one serves.
|
||||
- During a rolling upgrade, upgraded nodes answer `POST ?restore` with `503`
|
||||
while any peer is old or unreachable. Pause new restores until every node is
|
||||
upgraded. Multi-pool lifecycle protection is complete only after a full
|
||||
upgrade.
|
||||
- Stop batch submissions and drain or cancel unfinished jobs before changing
|
||||
versions. Unfinished legacy jobs require resubmission after all nodes upgrade;
|
||||
completed jobs stay completed. Submitter-bound manifests and progress reports
|
||||
use a new format that 20261026 cannot execute or resume. Do not downgrade
|
||||
with pending or paused new jobs. An old node can still execute old-format
|
||||
jobs, so mixed-version execution is not protected by the new authorization
|
||||
rules. Preserve job definitions and reconcile completed work before resubmitting.
|
||||
- `healthcheck ready` remains a process/dependency probe and can succeed before
|
||||
storage or IAM load. `cluster` reports quorum, which can precede a restarted
|
||||
node reconnecting its remote drives. Before resuming applications, run bounded
|
||||
signed write/read checks through every node; neither HTTP probe alone proves
|
||||
data-path readiness. Preserve peer discovery during bootstrap.
|
||||
- `helm upgrade --reuse-values` keeps the previously stored `image.tag`; set it
|
||||
explicitly when upgrading such a release.
|
||||
|
||||
Known issues: `ListMultipartUploads` prefix and paging
|
||||
([#79](https://github.com/pgsty/silo/issues/79)), multi-site delete-marker purge
|
||||
([#217](https://github.com/pgsty/silo/issues/217)), replayed bucket creation
|
||||
under site replication ([PR #241](https://github.com/pgsty/silo/pull/241)), and
|
||||
`403` instead of the server error for reads allowed only by object tags while
|
||||
the object is unreadable ([#293](https://github.com/pgsty/silo/issues/293)).
|
||||
|
||||
## RELEASE.2026-10-26T00-00-00Z
|
||||
|
||||
Package version: `20261026000000.0.0`; published from source `c3ad836aed0d`.
|
||||
|
||||
+5
-2
@@ -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.
|
||||
|
||||
|
||||
+78
-27
File diff suppressed because one or more lines are too long
@@ -17,9 +17,9 @@ ENV GOPATH=/go
|
||||
ENV CGO_ENABLED=0
|
||||
|
||||
ARG MC_REPO=pgsty/mc
|
||||
ARG MC_VERSION=RELEASE.2026-10-26T00-00-00Z
|
||||
ARG MC_AMD64_SHA256=e86295bb11ed59369c0560f030d59abd42fb64a376b1d7e9799ec2e8596ebf64
|
||||
ARG MC_ARM64_SHA256=47b1a558f766f26bef5beeedd5300e082bcdec07a5e7d4a16f5f9d168f5275d1
|
||||
ARG MC_VERSION=RELEASE.2026-11-09T00-00-00Z
|
||||
ARG MC_AMD64_SHA256=79e6cf4ad00a9e5eb2e7ceccc6e6496e4af4ec096e59695515ea8f223c287e4a
|
||||
ARG MC_ARM64_SHA256=5473c4b39a31dd66478f5c44686a09f3e9e37861a30f1faf18564fbe66118387
|
||||
|
||||
RUN apk add -U --no-cache \
|
||||
ca-certificates \
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -32,29 +32,26 @@
|
||||
> [!IMPORTANT]
|
||||
> **PGSTY Silo** (hereinafter “Silo”) is an independent, community-maintained fork of the open-source MinIO server, published by [Pigsty](https://pigsty.io) from [`pgsty/silo`](https://github.com/pgsty/silo). It is not affiliated with, endorsed by, or sponsored by MinIO, Inc. “MinIO” is used only to identify the upstream project and compatibility lineage.
|
||||
|
||||
> [!NOTE]
|
||||
> Renamed from `pgsty/minio` to `pgsty/silo`, default branch `master` → `main`, on 2026-08-06. Artifacts under the original MinIO identity stay published on the archived [`minio`](https://github.com/pgsty/silo/tree/minio) branch and in releases up to [`RELEASE.2026-08-04T00-00-00Z`](https://github.com/pgsty/silo/releases/tag/RELEASE.2026-08-04T00-00-00Z).
|
||||
|
||||
## SILO 20261026000000
|
||||
|
||||
[SILO 20261026000000](https://github.com/pgsty/silo/releases/tag/RELEASE.2026-10-26T00-00-00Z) is available as platform archives, Linux packages, and standard or distroless Docker images for amd64 and arm64. The release tag is `RELEASE.2026-10-26T00-00-00Z`; the package version is `20261026000000.0.0`, built from source `c3ad836aed0d`.
|
||||
|
||||
This release fixes LDAP long-DN login, IAM policy refresh, multi-pool deletion safety, tiered-object expiry, multipart replication checksums, and remote rollback-option propagation. It embeds Console v2.5.1 (`fff1618d9abb`) and uses silo-pkg v3.15.2, Go 1.27.3, and upstream minio-go `v7.4.2-0.20261025165926-320af050c445`. Use mcli 20261026 for administration; the container images bundle it as `mcli`.
|
||||
|
||||
The Server IAM cache repair enables Console's **Reset to default** action. Existing storage timeouts and cancellation behavior remain: a timeout does not prove that a disk operation stopped or a write did not commit. Read the [coordinated release notes](https://silo.pgsty.com/blog/release/silo-20261026/) and [remaining storage limits](CHANGELOG.md#release2026-10-26t00-00-00z) before upgrading. See [CONTRIBUTORS.md](CONTRIBUTORS.md) for the people behind the code, reports, and documentation.
|
||||
|
||||
## Overview
|
||||
|
||||
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) client (shipped as `mcli`) · [`pgsty/silo-console`](https://github.com/pgsty/silo-console) · [`pgsty/silo-pkg`](https://github.com/pgsty/silo-pkg) · [`pgsty/pigsty`](https://github.com/pgsty/pigsty)
|
||||
**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)
|
||||
|
||||
<p align="center">
|
||||
<img src="https://silo.pgsty.com/images/silo-console/console-metrics-simple.webp" alt="Silo Console">
|
||||
</p>
|
||||
|
||||
### SILO 20261109
|
||||
|
||||
> [!WARNING]
|
||||
> [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.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
@@ -62,7 +59,7 @@ docker run -d --name silo -p 9000:9000 -p 9001:9001 \
|
||||
-e MINIO_ROOT_USER=minioadmin \
|
||||
-e MINIO_ROOT_PASSWORD=change-me-long-password \
|
||||
-v "$PWD/data:/data" \
|
||||
docker.io/pgsty/silo:RELEASE.2026-10-26T00-00-00Z server /data --console-address ":9001"
|
||||
docker.io/pgsty/silo:RELEASE.2026-11-09T00-00-00Z server /data --console-address ":9001"
|
||||
```
|
||||
|
||||
<p align="center">
|
||||
@@ -89,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
|
||||
|
||||
@@ -101,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
|
||||
|
||||
+14
-17
@@ -32,29 +32,26 @@
|
||||
> [!IMPORTANT]
|
||||
> **PGSTY Silo**(以下简称 Silo)是由 [Pigsty](https://pigsty.cc) 独立维护、从 [`pgsty/silo`](https://github.com/pgsty/silo) 发布的开源 MinIO 社区分支。本项目与 MinIO, Inc. 不存在隶属、背书或赞助关系;文中使用 “MinIO” 仅用于说明上游项目及兼容谱系。
|
||||
|
||||
> [!NOTE]
|
||||
> 2026-08-06,本仓库由 `pgsty/minio` 更名为 `pgsty/silo`,默认分支由 `master` 更名为 `main`。以原 MinIO 形态维持的归档构件仍位于归档的 [`minio`](https://github.com/pgsty/silo/tree/minio) 分支,以及截止 [`RELEASE.2026-08-04T00-00-00Z`](https://github.com/pgsty/silo/releases/tag/RELEASE.2026-08-04T00-00-00Z) 的历次发布中。
|
||||
|
||||
## SILO 20261026000000
|
||||
|
||||
[SILO 20261026000000](https://github.com/pgsty/silo/releases/tag/RELEASE.2026-10-26T00-00-00Z) 已正式发布,提供各平台归档包、Linux 软件包,以及 amd64/arm64 标准版和 distroless Docker 镜像。版本标签为 `RELEASE.2026-10-26T00-00-00Z`,包版本为 `20261026000000.0.0`,构建源码为 `c3ad836aed0d`。
|
||||
|
||||
本版修复 LDAP 长 DN 登录、IAM 策略刷新、多池删除安全、分层对象过期、分片复制校验和及远端回滚参数透传。内置 Console v2.5.1(`fff1618d9abb`),使用 silo-pkg v3.15.2、Go 1.27.3 与上游 minio-go `v7.4.2-0.20261025165926-320af050c445`。配套管理客户端为 mcli 20261026,容器镜像内置该版本的 `mcli`。
|
||||
|
||||
Server IAM 缓存修复使 Console **Reset to default(恢复默认)** 操作即时生效。存储超时与取消行为保持既有语义:超时不代表底层磁盘操作已停止,也不代表写入未提交。升级前请阅读[联合发布说明](https://silo.pgsty.com/zh/blog/release/silo-20261026/)与[仍存的存储限制](CHANGELOG.md#release2026-10-26t00-00-00z)。代码、问题报告和文档贡献者见 [CONTRIBUTORS.md](CONTRIBUTORS.md)。
|
||||
|
||||
## 概述
|
||||
|
||||
上游停止社区发行后,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) 客户端(以 `mcli` 发行) · [`pgsty/silo-console`](https://github.com/pgsty/silo-console) · [`pgsty/silo-pkg`](https://github.com/pgsty/silo-pkg) · [`pgsty/pigsty`](https://github.com/pgsty/pigsty)
|
||||
**相关项目:**[`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)
|
||||
|
||||
<p align="center">
|
||||
<img src="https://silo.pgsty.com/images/silo-console/console-metrics-simple.webp" alt="Silo 控制台">
|
||||
</p>
|
||||
|
||||
### SILO 20261109
|
||||
|
||||
> [!WARNING]
|
||||
> [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)。
|
||||
|
||||
## 快速上手
|
||||
|
||||
```bash
|
||||
@@ -62,7 +59,7 @@ docker run -d --name silo -p 9000:9000 -p 9001:9001 \
|
||||
-e MINIO_ROOT_USER=minioadmin \
|
||||
-e MINIO_ROOT_PASSWORD=change-me-long-password \
|
||||
-v "$PWD/data:/data" \
|
||||
docker.io/pgsty/silo:RELEASE.2026-10-26T00-00-00Z server /data --console-address ":9001"
|
||||
docker.io/pgsty/silo:RELEASE.2026-11-09T00-00-00Z server /data --console-address ":9001"
|
||||
```
|
||||
|
||||
<p align="center">
|
||||
@@ -101,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/)。
|
||||
|
||||
## 文档归属
|
||||
|
||||
|
||||
+1
-1
@@ -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)
|
||||
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.
|
||||
|
||||
@@ -40,7 +40,7 @@ if [ -n "${MCLI_BIN:-}" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
release=${MCLI_RELEASE:-RELEASE.2026-10-26T00-00-00Z}
|
||||
release=${MCLI_RELEASE:-RELEASE.2026-11-09T00-00-00Z}
|
||||
version_hyphen=${release#RELEASE.}
|
||||
package_version=$(printf '%s\n' "${version_hyphen}" | sed -E 's/^([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2})-([0-9]{2})-([0-9]{2})Z$/\1\2\3\4\5\6.0.0/')
|
||||
if [ "${package_version}" = "${version_hyphen}" ]; then
|
||||
|
||||
@@ -112,8 +112,17 @@ helm_run template my-release "${new_chart}" \
|
||||
|
||||
go run ./buildscripts/helm-migration-guard "${old_render}" "${new_render}"
|
||||
|
||||
# The templates deploy values.image.tag, not appVersion; a release must move both.
|
||||
chart_version="$(sed -n 's/^version: *//p' helm/silo/Chart.yaml)"
|
||||
app_version="$(sed -n 's/^appVersion: *//p' helm/silo/Chart.yaml)"
|
||||
image_tag="$(sed -n '/^image:/,/^[^ ]/s/^ tag: *//p' helm/silo/values.yaml)"
|
||||
if [ -z "${app_version}" ] || [ "${image_tag}" != "${app_version}" ]; then
|
||||
echo "helm/silo: image.tag '${image_tag}' must equal appVersion '${app_version}'" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
helm_run package "${new_chart}" --destination "${output_dir}" >/dev/null
|
||||
test -s "${work_dir}/silo-7.0.3.tgz"
|
||||
test -s "${work_dir}/silo-${chart_version}.tgz"
|
||||
if find "${work_dir}" -maxdepth 1 -type f -name 'minio-*.tgz' | grep -q .; then
|
||||
echo "Helm packaging emitted a legacy MinIO chart name" >&2
|
||||
exit 1
|
||||
|
||||
@@ -169,8 +169,9 @@ func TestIAMAdminEmptyPolicyDelegation(t *testing.T) {
|
||||
{"{}", http.StatusOK, true},
|
||||
{`{"Statement":null}`, http.StatusOK, true},
|
||||
{limitedPolicy, http.StatusOK, false},
|
||||
{`{"Version":"2012-10-17"}`, http.StatusForbidden, false},
|
||||
{`{"Statement":[]}`, http.StatusForbidden, false},
|
||||
{`{"Version":"2012-10-17"}`, http.StatusOK, true},
|
||||
{`{"Statement":[]}`, http.StatusOK, true},
|
||||
{`{"Version":"2012-10-17","Statement":[]}`, http.StatusOK, true},
|
||||
} {
|
||||
expires := UTCNow().Add(time.Hour)
|
||||
req := madmin.AddServiceAccountReq{Expiration: &expires}
|
||||
|
||||
+26
-45
@@ -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,
|
||||
@@ -903,6 +903,9 @@ func (a adminAPIHandlers) UpdateServiceAccount(w http.ResponseWriter, r *http.Re
|
||||
description: updateReq.NewDescription,
|
||||
expiration: updateReq.NewExpiration,
|
||||
sessionPolicy: sp,
|
||||
// The edit applies to the stored account only while it still has
|
||||
// the parent the checks above authorized.
|
||||
parentUser: svcAccount.ParentUser,
|
||||
}
|
||||
updatedAt, err := globalIAMSys.UpdateServiceAccount(ctx, accessKey, opts)
|
||||
if err != nil {
|
||||
@@ -911,20 +914,12 @@ 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,
|
||||
},
|
||||
},
|
||||
SvcAccChange: &madmin.SRSvcAccChange{Update: &madmin.SRSvcAccUpdate{AccessKey: accessKey}},
|
||||
UpdatedAt: updatedAt,
|
||||
}))
|
||||
}
|
||||
@@ -2396,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)
|
||||
@@ -2647,8 +2618,17 @@ func (a adminAPIHandlers) importIAM(w http.ResponseWriter, r *http.Request, apiV
|
||||
return
|
||||
}
|
||||
}
|
||||
if _, gerr := globalIAMSys.AddUsersToGroup(ctx, group, grpInfo.Members); gerr != nil {
|
||||
failed.Groups = append(failed.Groups, madmin.IAMErrEntity{Name: group, Error: err})
|
||||
_, gerr := globalIAMSys.AddUsersToGroup(ctx, group, grpInfo.Members)
|
||||
if gerr == nil {
|
||||
switch grpInfo.Status {
|
||||
case statusDisabled:
|
||||
_, gerr = globalIAMSys.SetGroupStatus(ctx, group, false)
|
||||
case statusEnabled:
|
||||
_, gerr = globalIAMSys.SetGroupStatus(ctx, group, true)
|
||||
}
|
||||
}
|
||||
if gerr != nil {
|
||||
failed.Groups = append(failed.Groups, madmin.IAMErrEntity{Name: group, Error: gerr})
|
||||
} else {
|
||||
added.Groups = append(added.Groups, group)
|
||||
}
|
||||
@@ -2728,6 +2708,7 @@ func (a adminAPIHandlers) importIAM(w http.ResponseWriter, r *http.Request, apiV
|
||||
opts := newServiceAccountOpts{
|
||||
accessKey: user,
|
||||
secretKey: svcAcctReq.SecretKey,
|
||||
status: svcAcctReq.Status,
|
||||
sessionPolicy: sp,
|
||||
claims: svcAcctReq.Claims,
|
||||
name: svcAcctReq.Name,
|
||||
@@ -3084,7 +3065,7 @@ func commonAddServiceAccount(r *http.Request, ldap bool) (context.Context, auth.
|
||||
}
|
||||
// Empty service policies mean inheritance, including for expiring keys.
|
||||
// Normalize the replication request as well as the local credential.
|
||||
if sp.Version == "" && sp.Statements == nil && sp.ID == "" {
|
||||
if sp.IsEmpty() {
|
||||
sp = nil
|
||||
createReq.Policy = nil
|
||||
}
|
||||
|
||||
@@ -791,7 +791,7 @@ func (a adminAPIHandlers) MetricsHandler(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
enc := json.NewEncoder(w)
|
||||
for n > 0 {
|
||||
var m madmin.RealtimeMetrics
|
||||
var m realtimeMetrics
|
||||
mLocal := collectLocalMetrics(types, collectMetricsOpts{
|
||||
hosts: hostMap,
|
||||
disks: diskMap,
|
||||
@@ -2675,7 +2675,7 @@ func fetchHealthInfo(healthCtx context.Context, objectAPI ObjectLayer, query *ur
|
||||
// collect all realtime metrics except disk
|
||||
// disk metrics are already included under drive info of each server
|
||||
getRealtimeMetrics := func() *madmin.RealtimeMetrics {
|
||||
var m madmin.RealtimeMetrics
|
||||
var m realtimeMetrics
|
||||
types := madmin.MetricsAll &^ madmin.MetricsDisk
|
||||
mLocal := collectLocalMetrics(types, collectMetricsOpts{})
|
||||
m.Merge(&mLocal)
|
||||
@@ -2690,7 +2690,8 @@ func fetchHealthInfo(healthCtx context.Context, objectAPI ObjectLayer, query *ur
|
||||
m.ByHost[anonAddr(host)] = metrics
|
||||
delete(m.ByHost, host)
|
||||
}
|
||||
return &m
|
||||
legacy := m.legacy()
|
||||
return &legacy
|
||||
}
|
||||
|
||||
anonymizeCmdLine := func(cmdLine string) string {
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
// Copyright (c) 2026 Denis Babchuk
|
||||
//
|
||||
// This file is part of Silo Object Storage stack
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"maps"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/minio/minio-go/v7/pkg/set"
|
||||
"github.com/minio/minio-go/v7/pkg/tags"
|
||||
"github.com/minio/minio/internal/arn"
|
||||
"github.com/minio/minio/internal/auth"
|
||||
"github.com/minio/minio/internal/logger"
|
||||
"github.com/pgsty/silo-pkg/v3/policy"
|
||||
"github.com/pgsty/silo-pkg/v3/policy/condition"
|
||||
"github.com/puzpuzpuz/xsync/v3"
|
||||
)
|
||||
|
||||
// authorizeRequestWithUnreadTags decides whether to disclose readErr for an
|
||||
// object whose stored tags could not be read. It must not grant object access.
|
||||
//
|
||||
// Without the tags, an Allow statement that tests s3:ExistingObjectTag cannot
|
||||
// match, so a caller allowed only by such a statement would get AccessDenied,
|
||||
// which clients do not retry, for a transient server error such as lost read
|
||||
// quorum. For a server error, try a bounded set of tags from the caller's
|
||||
// policies and return the storage error if normal authorization accepts one.
|
||||
// If no candidate passes, keep AccessDenied. This is not an exhaustive policy
|
||||
// solver. For any other error, NoSuchKey among them, the tags stay empty.
|
||||
func authorizeRequestWithUnreadTags(ctx context.Context, r *http.Request, action policy.Action, readErr error) APIErrorCode {
|
||||
s3Err := authorizeRequest(ctx, r, action)
|
||||
if s3Err == ErrNone || toAPIError(ctx, readErr).HTTPStatusCode < http.StatusInternalServerError {
|
||||
return s3Err
|
||||
}
|
||||
return authorizeRequestWithTagCandidates(ctx, r, action, s3Err)
|
||||
}
|
||||
|
||||
// authorizeRequestWithPotentialTags performs the authorization needed before
|
||||
// a remote replica can be read. An empty tag set is enough to reject a caller
|
||||
// with no possible access, but a caller whose Allow depends on existing tags
|
||||
// must be admitted so the remote copy can supply the authoritative tags.
|
||||
// The returned decision is only a gate; the actual tags must be authorized
|
||||
// again after they are read.
|
||||
func authorizeRequestWithPotentialTags(ctx context.Context, r *http.Request, action policy.Action) APIErrorCode {
|
||||
s3Err := authorizeRequest(ctx, r, action)
|
||||
if s3Err == ErrNone {
|
||||
return ErrNone
|
||||
}
|
||||
return authorizeRequestWithTagCandidates(ctx, r, action, s3Err)
|
||||
}
|
||||
|
||||
func authorizeRequestWithTagCandidates(ctx context.Context, r *http.Request, action policy.Action, s3Err APIErrorCode) APIErrorCode {
|
||||
for _, existingTags := range policyExistingObjectTags(ctx, r, action) {
|
||||
if authorizeRequestWithExistingTags(ctx, r, action, existingTags) == ErrNone {
|
||||
return ErrNone
|
||||
}
|
||||
}
|
||||
return s3Err
|
||||
}
|
||||
|
||||
// maxExistingTagCandidates bounds the tag sets tried for one request; each
|
||||
// one costs an authorization.
|
||||
const maxExistingTagCandidates = 64
|
||||
|
||||
// policyExistingObjectTags returns tag sets under which the policies that
|
||||
// authorize the request may let action through on its object: the bucket
|
||||
// policy for an anonymous request, else the cached IAM policies and, as a
|
||||
// second policy set, the session policy of the credential. A tag set passes
|
||||
// the s3:ExistingObjectTag tests of one Allow statement of each policy set and,
|
||||
// as far as the same tags allow, keeps the Deny statements that test tags from
|
||||
// matching. The tag sets are only candidates; the regular authorization
|
||||
// decides what they allow.
|
||||
func policyExistingObjectTags(ctx context.Context, r *http.Request, action policy.Action) []string {
|
||||
reqInfo := logger.GetReqInfo(ctx)
|
||||
if reqInfo == nil {
|
||||
return nil
|
||||
}
|
||||
bucket, object := reqInfo.BucketName, reqInfo.ObjectName
|
||||
var (
|
||||
allows [][]map[string]string // per policy set, the tags its Allow statements ask for
|
||||
denies [][]map[string]string // per Deny statement, alternative tags that keep it from matching
|
||||
)
|
||||
// add collects the tags of a statement that applies to action on the
|
||||
// object, whatever its conditions.
|
||||
add := func(effect policy.Effect, conditions condition.Functions, values map[string][]string, fromSet *[]map[string]string) {
|
||||
candidates := existingObjectTagsCandidatesFor(effect, conditions, values)
|
||||
if effect == policy.Allow {
|
||||
*fromSet = append(*fromSet, candidates...)
|
||||
} else if len(candidates) > 0 {
|
||||
denies = append(denies, candidates)
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case reqInfo.Cred.AccessKey == "":
|
||||
p, err := globalPolicySys.Get(bucket)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
values := getConditionValues(r, reqInfo.Region, auth.AnonymousCredentials)
|
||||
var fromSet []map[string]string
|
||||
for _, st := range p.Statements {
|
||||
match := st
|
||||
match.Effect, match.Conditions = policy.Allow, nil
|
||||
if match.IsAllowed(policy.BucketPolicyArgs{Action: action, BucketName: bucket, ObjectName: object, ConditionValues: values}) {
|
||||
add(st.Effect, st.Conditions, values, &fromSet)
|
||||
}
|
||||
}
|
||||
allows = append(allows, fromSet)
|
||||
case newGlobalAuthZPluginFn() == nil && globalIAMSys.Initialized():
|
||||
values := getConditionValues(r, "", reqInfo.Cred)
|
||||
policySets := globalIAMSys.unreadTagPolicySets(reqInfo.Cred)
|
||||
for _, policies := range policySets {
|
||||
var fromSet []map[string]string
|
||||
for _, p := range policies {
|
||||
for _, st := range p.Statements {
|
||||
match := st
|
||||
match.Effect, match.Conditions = policy.Allow, nil
|
||||
if match.IsAllowed(policy.Args{Action: action, BucketName: bucket, ObjectName: object, ConditionValues: values}) {
|
||||
add(st.Effect, st.Conditions, values, &fromSet)
|
||||
}
|
||||
}
|
||||
}
|
||||
allows = append(allows, fromSet)
|
||||
}
|
||||
}
|
||||
return combineExistingObjectTags(append(allows, denies...))
|
||||
}
|
||||
|
||||
// unreadTagMappings reuses the normal user/group mapping lookup without lazy
|
||||
// storage reads. Missing cache entries contribute no candidates; the regular
|
||||
// authorization still decides whether any candidate is allowed.
|
||||
type unreadTagMappings struct {
|
||||
*IAMStoreSys
|
||||
}
|
||||
|
||||
func (unreadTagMappings) loadMappedPolicy(context.Context, string, IAMUserType, bool, *xsync.MapOf[string, MappedPolicy]) error {
|
||||
return errNoSuchPolicy
|
||||
}
|
||||
|
||||
func (unreadTagMappings) loadGroup(context.Context, string, map[string]GroupInfo) error {
|
||||
return errNoSuchGroup
|
||||
}
|
||||
|
||||
// unreadTagPolicySets selects only this credential's cached policies. Base
|
||||
// and session policies remain separate because both must allow the request.
|
||||
// This only supplies candidates, so identity, claims and policy validity are
|
||||
// left to the normal authorization on each candidate.
|
||||
func (sys *IAMSys) unreadTagPolicySets(cred auth.Credentials) [][]policy.Policy {
|
||||
derived := cred.IsTemp() || cred.IsServiceAccount()
|
||||
name := cred.AccessKey
|
||||
if derived {
|
||||
name = cred.ParentUser
|
||||
}
|
||||
cache := sys.store.rlock()
|
||||
defer sys.store.runlock()
|
||||
var names []string
|
||||
roleARN := (policy.Args{Claims: cred.Claims}).GetRoleArn()
|
||||
switch {
|
||||
case name == globalActiveCred.AccessKey:
|
||||
case derived && roleARN != "":
|
||||
parsed, err := arn.Parse(roleARN)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
names = newMappedPolicy(sys.rolesMap[parsed]).toSlice()
|
||||
default:
|
||||
loader := unreadTagMappings{sys.store}
|
||||
var err error
|
||||
names, _, err = cache.policyDBGet(loader, name, false, false)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
groups, err := cache.policyDBGetGroups(loader, len(names) > 0, cred.Groups...)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
names = append(names, groups...)
|
||||
if derived && len(names) == 0 {
|
||||
claims, _ := policy.GetPoliciesFromClaims(cred.Claims, iamPolicyClaimNameOpenID())
|
||||
names = claims.ToSlice()
|
||||
}
|
||||
}
|
||||
|
||||
sets := [][]policy.Policy{nil}
|
||||
slices.Sort(names)
|
||||
for _, name := range slices.Compact(names) {
|
||||
if doc, ok := cache.iamPolicyDocsMap[name]; ok {
|
||||
sets[0] = append(sets[0], doc.Policy)
|
||||
}
|
||||
}
|
||||
if derived && (cred.IsTemp() || cred.Claims[iamPolicyClaimNameSA()] != inheritedPolicyType) {
|
||||
if sp, ok := cred.Claims[sessionPolicyNameExtracted].(string); ok {
|
||||
if p, err := policy.ParseConfig(strings.NewReader(sp)); err == nil {
|
||||
sets = append(sets, []policy.Policy{*p})
|
||||
}
|
||||
}
|
||||
}
|
||||
return sets
|
||||
}
|
||||
|
||||
// combineExistingObjectTags combines alternatives from each base/session
|
||||
// Allow set and each tag-dependent Deny. A set may also contribute no tags:
|
||||
// another statement or another set may already supply what it needs. First
|
||||
// values win conflicts; the normal authorization decides whether they work.
|
||||
func combineExistingObjectTags(sets [][]map[string]string) []string {
|
||||
combos := []map[string]string{{}}
|
||||
for _, fromSet := range sets {
|
||||
fromSet = append(uniqueExistingObjectTagMaps(fromSet), nil)
|
||||
var next []map[string]string
|
||||
seen := set.NewStringSet()
|
||||
combinations:
|
||||
for _, combo := range combos {
|
||||
for _, addition := range fromSet {
|
||||
merged := maps.Clone(combo)
|
||||
for k, v := range addition {
|
||||
if _, ok := merged[k]; !ok {
|
||||
merged[k] = v
|
||||
}
|
||||
}
|
||||
t, err := tags.MapToObjectTags(merged)
|
||||
if err != nil || seen.Contains(t.String()) {
|
||||
continue
|
||||
}
|
||||
seen.Add(t.String())
|
||||
next = append(next, merged)
|
||||
if len(next) == maxExistingTagCandidates {
|
||||
break combinations
|
||||
}
|
||||
}
|
||||
}
|
||||
combos = next
|
||||
}
|
||||
var candidates []string
|
||||
for _, combo := range combos {
|
||||
if len(combo) != 0 { // empty tags were already authorized
|
||||
t, _ := tags.MapToObjectTags(combo)
|
||||
candidates = append(candidates, t.String())
|
||||
}
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
// existingObjectTagsCandidatesFor returns tag sets under which the
|
||||
// s3:ExistingObjectTag tests of conditions all pass, for an Allow statement,
|
||||
// or one of them fails, for a Deny statement; a tag left out is absent. For an
|
||||
// Allow, values for different tag keys are combined, while a Deny needs only
|
||||
// one failing key. It tries the values named in the conditions, with policy
|
||||
// variables resolved, wildcard fillers, and absence, then lets the policy
|
||||
// engine decide whether each candidate really satisfies the conditions.
|
||||
func existingObjectTagsCandidatesFor(effect policy.Effect, conditions condition.Functions, values map[string][]string) []map[string]string {
|
||||
// Each function tests one key, so the tests of one tag pass or fail
|
||||
// whatever the other tags are.
|
||||
byKey := make(map[string]condition.Functions)
|
||||
for _, f := range conditions {
|
||||
for key := range condition.NewFunctions(f).Keys() {
|
||||
if key.Is(condition.ExistingObjectTag) {
|
||||
byKey[key.Name()] = append(byKey[key.Name()], f)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(byKey) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
keys := slices.Sorted(maps.Keys(byKey))
|
||||
if effect == policy.Deny {
|
||||
var candidates []map[string]string
|
||||
for _, key := range keys {
|
||||
candidates = append(candidates, existingObjectTagValues(key, strings.TrimPrefix(key, condition.ExistingObjectTag.Name()+"/"), byKey[key], values, false)...)
|
||||
}
|
||||
return uniqueExistingObjectTagMaps(candidates)
|
||||
}
|
||||
|
||||
candidates := []map[string]string{{}}
|
||||
for _, key := range keys {
|
||||
keyCandidates := existingObjectTagValues(key, strings.TrimPrefix(key, condition.ExistingObjectTag.Name()+"/"), byKey[key], values, true)
|
||||
if len(keyCandidates) == 0 {
|
||||
return nil
|
||||
}
|
||||
next := make([]map[string]string, 0, min(maxExistingTagCandidates, len(candidates)*len(keyCandidates)))
|
||||
for _, base := range candidates {
|
||||
for _, keyCandidate := range keyCandidates {
|
||||
merged := maps.Clone(base)
|
||||
maps.Copy(merged, keyCandidate)
|
||||
next = append(next, merged)
|
||||
if len(next) == maxExistingTagCandidates {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(next) == maxExistingTagCandidates {
|
||||
break
|
||||
}
|
||||
}
|
||||
candidates = next
|
||||
}
|
||||
return uniqueExistingObjectTagMaps(candidates)
|
||||
}
|
||||
|
||||
func uniqueExistingObjectTagMaps(candidates []map[string]string) []map[string]string {
|
||||
seen := set.NewStringSet()
|
||||
unique := make([]map[string]string, 0, len(candidates))
|
||||
for _, candidate := range candidates {
|
||||
t, err := tags.MapToObjectTags(candidate)
|
||||
if err != nil || seen.Contains(t.String()) {
|
||||
continue
|
||||
}
|
||||
seen.Add(t.String())
|
||||
unique = append(unique, candidate)
|
||||
}
|
||||
return unique
|
||||
}
|
||||
|
||||
// existingObjectTagValues looks for values of tag tagKey, or its absence,
|
||||
// under which the functions that test the condition key key evaluate to want.
|
||||
func existingObjectTagValues(key, tagKey string, tests condition.Functions, values map[string][]string, want bool) []map[string]string {
|
||||
defer delete(values, key)
|
||||
delete(values, key)
|
||||
var candidates []map[string]string
|
||||
if tests.Evaluate(values) == want {
|
||||
candidates = append(candidates, map[string]string{})
|
||||
}
|
||||
// The functions keep their values unexported; JSON exposes them.
|
||||
buf, err := json.Marshal(tests)
|
||||
if err != nil {
|
||||
return candidates
|
||||
}
|
||||
var byName map[string]map[string]condition.ValueSet
|
||||
if err = json.Unmarshal(buf, &byName); err != nil {
|
||||
return candidates
|
||||
}
|
||||
var tried []string
|
||||
for name, byKey := range byName {
|
||||
if name == "Null" {
|
||||
continue // its operand says whether the tag is there, not a value
|
||||
}
|
||||
for _, valueSet := range byKey {
|
||||
for _, v := range valueSet.ToSlice() {
|
||||
s := substituteConditionVariables(v.String(), values)
|
||||
tried = append(tried, s, strings.NewReplacer("*", "", "?", "_").Replace(s))
|
||||
}
|
||||
}
|
||||
}
|
||||
slices.Sort(tried)
|
||||
for _, v := range append(slices.Compact(tried), "", "_") {
|
||||
if _, err := tags.MapToObjectTags(map[string]string{tagKey: v}); err != nil {
|
||||
continue
|
||||
}
|
||||
values[key] = []string{v}
|
||||
if tests.Evaluate(values) == want {
|
||||
candidates = append(candidates, map[string]string{tagKey: v})
|
||||
if len(candidates) == maxExistingTagCandidates {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return uniqueExistingObjectTagMaps(candidates)
|
||||
}
|
||||
|
||||
// substituteConditionVariables resolves the policy variables in v from the
|
||||
// condition values as the policy engine does for the values of a condition;
|
||||
// silo-pkg keeps its own function for this unexported.
|
||||
func substituteConditionVariables(v string, values map[string][]string) string {
|
||||
for _, key := range condition.CommonKeys {
|
||||
if rvalues, ok := values[key.Name()]; ok && len(rvalues) > 0 && rvalues[0] != "" {
|
||||
v = strings.ReplaceAll(v, key.VarName(), rvalues[0])
|
||||
}
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
// Copyright (c) 2026 Denis Babchuk
|
||||
//
|
||||
// This file is part of Silo Object Storage stack
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/minio/minio-go/v7/pkg/tags"
|
||||
"github.com/minio/minio/internal/arn"
|
||||
"github.com/minio/minio/internal/auth"
|
||||
xhttp "github.com/minio/minio/internal/http"
|
||||
"github.com/pgsty/silo-pkg/v3/policy"
|
||||
"github.com/pgsty/silo-pkg/v3/policy/condition"
|
||||
)
|
||||
|
||||
// objectReadFault takes the first n drives of the set that holds object
|
||||
// offline for the metadata reads of that object only, so that IAM and bucket
|
||||
// metadata stay readable. It returns a function that restores the drives.
|
||||
func objectReadFault(z *erasureServerPools, bucket, object string, n int) func() {
|
||||
pool := z.serverPools[0]
|
||||
setIndex := pool.getHashedSetIndex(object)
|
||||
pool.erasureDisksMu.Lock()
|
||||
defer pool.erasureDisksMu.Unlock()
|
||||
previous := pool.erasureDisks[setIndex]
|
||||
disks := append([]StorageAPI(nil), previous...)
|
||||
for i := range n {
|
||||
disks[i] = consistencyReadFaultDisk{StorageAPI: disks[i], bucket: bucket, object: object}
|
||||
}
|
||||
pool.erasureDisks[setIndex] = disks
|
||||
return func() {
|
||||
pool.erasureDisksMu.Lock()
|
||||
pool.erasureDisks[setIndex] = previous
|
||||
pool.erasureDisksMu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// A caller allowed to read an object only through s3:ExistingObjectTag must
|
||||
// get the storage error, which clients retry, and not AccessDenied, when the
|
||||
// object's tags cannot be read because read quorum is lost. A caller that no
|
||||
// tags would let through must still get AccessDenied, and nothing changes
|
||||
// while the tags can be read.
|
||||
func TestExistingObjectTagAuthReadQuorumLost(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
obj, dirs, err := prepareErasure16(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
z := obj.(*erasureServerPools)
|
||||
previous := newObjectLayerFn()
|
||||
setObjectLayer(z)
|
||||
defer func() {
|
||||
z.Shutdown(context.Background())
|
||||
removeRoots(dirs)
|
||||
setObjectLayer(previous)
|
||||
}()
|
||||
bucket, router, err := initAPIHandlerTest(ctx, z, nil, MakeBucketOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
statement := func(effect, resource, conditions string) string {
|
||||
st := fmt.Sprintf(`{"Effect": %q, "Action": ["s3:GetObject", "s3:GetObjectTagging", "s3:PutObjectTagging", "s3:DeleteObjectTagging"], "Resource": ["arn:aws:s3:::%s/%s"]`,
|
||||
effect, bucket, resource)
|
||||
if conditions != "" {
|
||||
st += `, "Condition": ` + conditions
|
||||
}
|
||||
return st + "}"
|
||||
}
|
||||
tagStatement := func(effect, resource, value string) string {
|
||||
return statement(effect, resource, fmt.Sprintf(`{"StringEquals": {"s3:ExistingObjectTag/k": %q}}`, value))
|
||||
}
|
||||
newUser := func(statements ...string) auth.Credentials {
|
||||
return newDeleteAuthzPolicyUser(t, "", bucket, "["+strings.Join(statements, ",")+"]")
|
||||
}
|
||||
newServiceAccount := func(parent auth.Credentials, statements string) auth.Credentials {
|
||||
sessionPolicy, err := policy.ParseConfig(strings.NewReader(`{"Version": "2012-10-17", "Statement": [` + statements + `]}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cred, _, err := globalIAMSys.NewServiceAccount(ctx, parent.AccessKey, nil, newServiceAccountOpts{sessionPolicy: sessionPolicy})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return cred
|
||||
}
|
||||
newTempUser := func(parent auth.Credentials, statements string) auth.Credentials {
|
||||
session := `{"Version": "2012-10-17", "Statement": [` + statements + `]}`
|
||||
secret, err := getTokenSigningKey()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cred, err := auth.GetNewCredentialsWithMetadata(map[string]any{
|
||||
parentClaim: parent.AccessKey, "exp": UTCNow().Add(time.Hour).Unix(),
|
||||
policy.SessionPolicyName: base64.StdEncoding.EncodeToString([]byte(session)),
|
||||
}, secret)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cred.ParentUser = parent.AccessKey
|
||||
if _, err := globalIAMSys.SetTempUser(ctx, cred.AccessKey, cred, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return cred
|
||||
}
|
||||
// An unrelated cached policy must not consume the candidate limit. The
|
||||
// target user's policy is created below and sorts after this policy.
|
||||
fillerStatements := make([]string, 0, maxExistingTagCandidates)
|
||||
for i := range maxExistingTagCandidates {
|
||||
fillerStatements = append(fillerStatements, statement("Allow", "*",
|
||||
fmt.Sprintf(`{"StringEquals": {"s3:ExistingObjectTag/fill%02d": "v"}}`, i)))
|
||||
}
|
||||
fillerPolicy, err := policy.ParseConfig(strings.NewReader(`{"Version": "2012-10-17", "Statement": [` + strings.Join(fillerStatements, ",") + `]}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fillerPolicyName := "000-unread-tags-filler-" + mustGetUUID()
|
||||
if _, err = globalIAMSys.SetPolicy(ctx, fillerPolicyName, *fillerPolicy); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
if err := globalIAMSys.DeletePolicy(ctx, fillerPolicyName, false); err != nil {
|
||||
t.Logf("delete filler policy: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
type user struct {
|
||||
name string
|
||||
creds auth.Credentials
|
||||
// The status with the object's tags readable and with read quorum
|
||||
// lost: 200 and 503 for a caller that some tags let through, 403
|
||||
// and 403 for a caller that none do.
|
||||
healthy, quorumLost int
|
||||
}
|
||||
allowedByTags := func(name string, creds auth.Credentials) user {
|
||||
return user{name, creds, http.StatusOK, http.StatusServiceUnavailable}
|
||||
}
|
||||
deniedByAnyTags := func(name string, creds auth.Credentials) user {
|
||||
return user{name, creds, http.StatusForbidden, http.StatusForbidden}
|
||||
}
|
||||
// Each case below that tests a tag key of its own uses a key no other
|
||||
// policy asks for.
|
||||
ownerCondition := `{"StringEquals": {"s3:ExistingObjectTag/owner": "${aws:username}"}}`
|
||||
owner := newUser(statement("Allow", "*", ownerCondition))
|
||||
users := []user{
|
||||
// Allowed only while the object carries k=v.
|
||||
allowedByTags("tagged", newUser(tagStatement("Allow", "*", "v"))),
|
||||
// Allowed while the object carries a tag np, whatever its value.
|
||||
allowedByTags("nullFalse", newUser(statement("Allow", "*", `{"Null": {"s3:ExistingObjectTag/np": "false"}}`))),
|
||||
// Allowed while a tag ne is there and is not w.
|
||||
allowedByTags("notEquals", newUser(statement("Allow", "*",
|
||||
`{"Null": {"s3:ExistingObjectTag/ne": "false"}, "StringNotEquals": {"s3:ExistingObjectTag/ne": "w"}}`))),
|
||||
// Allowed while every value of tag fa differs from w; that needs a tag fa.
|
||||
allowedByTags("forAllNotEquals", newUser(statement("Allow", "*", `{"ForAllValues:StringNotEquals": {"s3:ExistingObjectTag/fa": "w"}}`))),
|
||||
// Allowed while the object is tagged with the caller's name.
|
||||
allowedByTags("owner", owner),
|
||||
// Allowed, but denied unless the object carries du=1.
|
||||
allowedByTags("denyUnlessTagged", newUser(statement("Allow", "*", ""),
|
||||
statement("Deny", "*", `{"StringNotEquals": {"s3:ExistingObjectTag/du": "1"}}`))),
|
||||
// A service account whose parent asks for k=v and whose own policy
|
||||
// asks for j=w: only an object carrying both lets it through.
|
||||
allowedByTags("serviceAccount", newServiceAccount(newUser(tagStatement("Allow", "*", "v")),
|
||||
statement("Allow", "*", `{"StringEquals": {"s3:ExistingObjectTag/j": "w"}}`))),
|
||||
allowedByTags("temporary", newTempUser(newUser(tagStatement("Allow", "*", "v")),
|
||||
statement("Allow", "*", `{"StringEquals": {"s3:ExistingObjectTag/j": "w"}}`))),
|
||||
allowedByTags("rootTemporary", newTempUser(globalActiveCred, tagStatement("Allow", "*", "v"))),
|
||||
allowedByTags("rootServiceAccount", newServiceAccount(globalActiveCred, tagStatement("Allow", "*", "v"))),
|
||||
// The effective policy must be selected before applying the candidate
|
||||
// limit; the unrelated cached policy above has 64 other tag statements.
|
||||
allowedByTags("unrelatedPolicies", newUser(statement("Allow", "*",
|
||||
`{"StringEquals": {"s3:ExistingObjectTag/target": "ok"}}`))),
|
||||
// An alternative allowed value must survive a Deny that excludes the
|
||||
// first value. With unread tags, k=v is the only valid candidate.
|
||||
allowedByTags("alternativeAllowedValue", newUser(
|
||||
statement("Allow", "*", `{"StringEquals": {"s3:ExistingObjectTag/k": ["a", "v"]}}`),
|
||||
tagStatement("Deny", "*", "a"))),
|
||||
allowedByTags("alternativeValuesAcrossKeys", newUser(
|
||||
statement("Allow", "*", `{"StringEquals": {"s3:ExistingObjectTag/k": ["a", "v"], "s3:ExistingObjectTag/j": ["b", "w"]}}`),
|
||||
tagStatement("Deny", "*", "a"),
|
||||
statement("Deny", "*", `{"StringEquals": {"s3:ExistingObjectTag/j": "b"}}`))),
|
||||
// Both Deny statements must be avoided. The first candidate (k="")
|
||||
// avoids the Null Deny but still matches the second Deny.
|
||||
allowedByTags("multipleDenies", newUser(statement("Allow", "*", ""),
|
||||
statement("Deny", "*", `{"Null": {"s3:ExistingObjectTag/k": "true"}}`),
|
||||
tagStatement("Deny", "*", ""))),
|
||||
// The Deny can be avoided by k=v OR j=w. Only j=w is compatible
|
||||
// with this Allow, so stopping at the first Deny key loses it.
|
||||
{
|
||||
"alternativeDenyKey", newUser(tagStatement("Allow", "*", "a"),
|
||||
statement("Deny", "*", `{"StringNotEquals": {"s3:ExistingObjectTag/k": "v", "s3:ExistingObjectTag/j": "w"}}`)),
|
||||
http.StatusForbidden, http.StatusServiceUnavailable,
|
||||
},
|
||||
|
||||
// Allowed only for k=w: the decision still depends on the tags.
|
||||
{"otherValue", newUser(tagStatement("Allow", "*", "w")), http.StatusForbidden, http.StatusServiceUnavailable},
|
||||
|
||||
// Allowed for k=v under another prefix only.
|
||||
deniedByAnyTags("otherPrefix", newUser(tagStatement("Allow", "other/*", "v"))),
|
||||
// Allowed and denied for k=v alike.
|
||||
deniedByAnyTags("allowDeny", newUser(tagStatement("Allow", "*", "v"), tagStatement("Deny", "*", "v"))),
|
||||
// Allowed only without a tag nc that is 1.
|
||||
deniedByAnyTags("nullConflict", newUser(statement("Allow", "*",
|
||||
`{"Null": {"s3:ExistingObjectTag/nc": "true"}, "StringEquals": {"s3:ExistingObjectTag/nc": "1"}}`))),
|
||||
// Tagged with the caller's name, under another prefix only.
|
||||
deniedByAnyTags("ownerPrefix", newUser(statement("Allow", "other/*", ownerCondition))),
|
||||
// Denied unless the object carries db=1, and denied if it does.
|
||||
deniedByAnyTags("denyBoth", newUser(statement("Allow", "*", ""),
|
||||
statement("Deny", "*", `{"StringNotEquals": {"s3:ExistingObjectTag/db": "1"}}`),
|
||||
statement("Deny", "*", `{"StringEquals": {"s3:ExistingObjectTag/db": "1"}}`))),
|
||||
// The parent asks for k=v and the service account for k=x.
|
||||
deniedByAnyTags("serviceAccountConflict", newServiceAccount(newUser(tagStatement("Allow", "*", "v")),
|
||||
tagStatement("Allow", "*", "x"))),
|
||||
}
|
||||
|
||||
const object, missing = "tagged", "missing"
|
||||
if _, err := z.PutObject(ctx, bucket, object, mustGetPutObjReader(t, bytes.NewReader([]byte("data")), 4, "", ""),
|
||||
ObjectOptions{UserDefined: map[string]string{xhttp.AmzObjectTagging: "k=v&j=w&np=1&ne=1&fa=1&du=1&target=ok&owner=" + owner.AccessKey}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Anonymous reads are allowed by the bucket policy while the object carries k=v.
|
||||
if _, err := globalBucketMetadataSys.Update(ctx, bucket, bucketPolicyConfig, []byte(`{"Version": "2012-10-17", "Statement": [
|
||||
{"Effect": "Allow", "Principal": {"AWS": ["*"]}, "Action": ["s3:GetObject"], "Resource": ["arn:aws:s3:::`+bucket+`/*"],
|
||||
"Condition": {"StringEquals": {"s3:ExistingObjectTag/k": "v"}}}]}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
type request struct {
|
||||
name string
|
||||
method, target string
|
||||
header map[string]string
|
||||
body []byte
|
||||
}
|
||||
objectURL := getGetObjectURL("", bucket, object)
|
||||
get := request{"GET", http.MethodGet, objectURL, nil, nil}
|
||||
head := request{"HEAD", http.MethodHead, objectURL, nil, nil}
|
||||
taggingURL := makeTestTargetURL("", bucket, object, url.Values{"tagging": []string{""}})
|
||||
taggingBody := []byte(`<Tagging><TagSet><Tag><Key>k</Key><Value>v</Value></Tag></TagSet></Tagging>`)
|
||||
putTagging := request{"PutObjectTagging", http.MethodPut, taggingURL, nil, taggingBody}
|
||||
getTagging := request{"GetObjectTagging", http.MethodGet, taggingURL, nil, nil}
|
||||
deleteTagging := request{"DeleteObjectTagging", http.MethodDelete, taggingURL, nil, nil}
|
||||
check := func(stage string, creds auth.Credentials, name string, req request, want int) {
|
||||
t.Helper()
|
||||
var rec *httptest.ResponseRecorder
|
||||
if creds.AccessKey == "" {
|
||||
anonymous, err := newTestRequest(req.method, req.target, 0, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, anonymous)
|
||||
} else {
|
||||
var body io.ReadSeeker
|
||||
if req.body != nil {
|
||||
body = bytes.NewReader(req.body)
|
||||
}
|
||||
header := make(map[string]string)
|
||||
for k, v := range req.header {
|
||||
header[k] = v
|
||||
}
|
||||
if creds.IsTemp() {
|
||||
header[xhttp.AmzSecurityToken] = creds.SessionToken
|
||||
}
|
||||
signed, err := newTestSignedRequestV4(req.method, req.target, int64(len(req.body)), body, creds.AccessKey, creds.SecretKey, header)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, signed)
|
||||
}
|
||||
if rec.Code != want {
|
||||
var apiErr APIErrorResponse
|
||||
_ = xml.Unmarshal(rec.Body.Bytes(), &apiErr)
|
||||
t.Errorf("%s: %s by %s: status %d %s, want %d", stage, req.name, name, rec.Code, apiErr.Code, want)
|
||||
}
|
||||
}
|
||||
|
||||
var anonymous auth.Credentials
|
||||
for _, u := range users {
|
||||
check("healthy", u.creds, u.name, get, u.healthy)
|
||||
check("healthy", u.creds, u.name, getTagging, u.healthy)
|
||||
}
|
||||
check("healthy", anonymous, "anonymous", get, http.StatusOK)
|
||||
// The tags were read and allow the request: the range error is the answer.
|
||||
check("healthy", users[0].creds, users[0].name,
|
||||
request{"GET bytes=100-200", http.MethodGet, objectURL, map[string]string{xhttp.Range: "bytes=100-200"}, nil},
|
||||
http.StatusRequestedRangeNotSatisfiable)
|
||||
// Without an object there are no tags: AccessDenied, as before.
|
||||
missingURL := getGetObjectURL("", bucket, missing)
|
||||
check("missing key", users[0].creds, users[0].name, request{"GET", http.MethodGet, missingURL, nil, nil}, http.StatusForbidden)
|
||||
check("missing key", users[0].creds, users[0].name, request{"HEAD", http.MethodHead, missingURL, nil, nil}, http.StatusForbidden)
|
||||
check("missing key", users[0].creds, users[0].name, request{"GetObjectTagging", http.MethodGet, missingURL + "?tagging", nil, nil}, http.StatusForbidden)
|
||||
|
||||
defer objectReadFault(z, bucket, object, 10)()
|
||||
if _, err := z.GetObjectInfo(ctx, bucket, object, ObjectOptions{}); !isErrReadQuorum(err) {
|
||||
t.Fatalf("GetObjectInfo with 10 of 16 drives failing: %v, want InsufficientReadQuorum", err)
|
||||
}
|
||||
for _, req := range []request{get, head, getTagging, putTagging, deleteTagging} {
|
||||
for _, u := range users {
|
||||
check("read quorum lost", u.creds, u.name, req, u.quorumLost)
|
||||
}
|
||||
}
|
||||
check("read quorum lost", anonymous, "anonymous", get, http.StatusServiceUnavailable)
|
||||
check("read quorum lost", anonymous, "anonymous", head, http.StatusServiceUnavailable)
|
||||
}
|
||||
|
||||
func TestExistingObjectTagsFor(t *testing.T) {
|
||||
values := map[string][]string{"username": {"alice"}, "sub": {"u1"}}
|
||||
for _, tc := range []struct {
|
||||
effect policy.Effect
|
||||
conditions string
|
||||
want string // the tags to set, encoded as in x-amz-tagging
|
||||
ok bool // false: no tags for this statement
|
||||
}{
|
||||
{policy.Allow, `{"StringEquals": {"s3:ExistingObjectTag/k": "v"}}`, "k=v", true},
|
||||
{policy.Allow, `{"StringEqualsIgnoreCase": {"s3:ExistingObjectTag/k": "V"}, "IpAddress": {"aws:SourceIp": "10.0.0.0/8"}}`, "k=V", true},
|
||||
{policy.Allow, `{"ForAnyValue:StringEquals": {"s3:ExistingObjectTag/k": ["b", "a"]}}`, "k=a", true},
|
||||
{policy.Allow, `{"StringLike": {"s3:ExistingObjectTag/k": "a?c*"}}`, "k=a_c", true},
|
||||
{policy.Allow, `{"StringEquals": {"s3:ExistingObjectTag/b": "2", "s3:ExistingObjectTag/a": "1"}}`, "a=1&b=2", true},
|
||||
// Passed without the tag.
|
||||
{policy.Allow, `{"StringNotEquals": {"s3:ExistingObjectTag/k": "v"}}`, "", true},
|
||||
{policy.Allow, `{"Null": {"s3:ExistingObjectTag/k": "true"}}`, "", true},
|
||||
// Null "false" asks for the tag, with any value.
|
||||
{policy.Allow, `{"Null": {"s3:ExistingObjectTag/k": "false"}}`, "k=", true},
|
||||
{policy.Allow, `{"Null": {"s3:ExistingObjectTag/k": "false"}, "StringNotEquals": {"s3:ExistingObjectTag/k": ["", "w"]}}`, "k=_", true},
|
||||
{policy.Allow, `{"Null": {"s3:ExistingObjectTag/k": "false"}, "StringNotLike": {"s3:ExistingObjectTag/k": "*w"}}`, "k=", true},
|
||||
{policy.Allow, `{"ForAllValues:StringNotEquals": {"s3:ExistingObjectTag/k": "w"}}`, "k=", true},
|
||||
{policy.Allow, `{"Null": {"s3:ExistingObjectTag/k": "true"}, "StringEquals": {"s3:ExistingObjectTag/k": "v"}}`, "", false},
|
||||
// Policy variables take the values of the request.
|
||||
{policy.Allow, `{"StringEquals": {"s3:ExistingObjectTag/owner": "${aws:username}"}}`, "owner=alice", true},
|
||||
{policy.Allow, `{"StringLike": {"s3:ExistingObjectTag/owner": "${jwt:sub}-*"}}`, "owner=u1-", true},
|
||||
{policy.Allow, `{"StringEquals": {"s3:ExistingObjectTag/owner": "${ldap:user}"}}`, "", false},
|
||||
// A Deny is kept from matching.
|
||||
{policy.Deny, `{"StringNotEquals": {"s3:ExistingObjectTag/k": "v"}}`, "k=v", true},
|
||||
{policy.Deny, `{"Null": {"s3:ExistingObjectTag/k": "true"}}`, "k=", true},
|
||||
{policy.Deny, `{"StringEquals": {"s3:ExistingObjectTag/k": "v"}}`, "", true},
|
||||
{policy.Deny, `{"StringEquals": {"s3:prefix": "v"}}`, "", false},
|
||||
// No tag tests.
|
||||
{policy.Allow, `{"StringEquals": {"s3:prefix": "v"}}`, "", false},
|
||||
{policy.Allow, "", "", false},
|
||||
} {
|
||||
var conditions condition.Functions // nil: a statement without a Condition
|
||||
if tc.conditions != "" {
|
||||
if err := json.Unmarshal([]byte(tc.conditions), &conditions); err != nil {
|
||||
t.Fatalf("%s: %v", tc.conditions, err)
|
||||
}
|
||||
}
|
||||
candidates := existingObjectTagsCandidatesFor(tc.effect, conditions, values)
|
||||
ok := len(candidates) > 0
|
||||
got := ""
|
||||
if ok {
|
||||
encoded, err := tags.MapToObjectTags(candidates[0])
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", tc.conditions, err)
|
||||
}
|
||||
got = encoded.String()
|
||||
}
|
||||
if got != tc.want || ok != tc.ok {
|
||||
t.Errorf("%s %s: got %q %v, want %q %v", tc.effect, tc.conditions, got, ok, tc.want, tc.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Group-only and external identities need mapping lookups that normally
|
||||
// fall back to storage. Candidate collection must stay within the cache even
|
||||
// for those identities, while still finding their group/claim/role policy.
|
||||
func TestUnreadTagPolicySetsCacheOnly(t *testing.T) {
|
||||
ctx, sys, _ := prepareIAMRevisionFixture(t)
|
||||
const policyName = "unread-tag-policy"
|
||||
batchAuthzPolicy(ctx, t, sys, policyName, `{"Effect":"Allow","Action":["s3:GetObject"],"Resource":["arn:aws:s3:::bucket/*"],"Condition":{"StringEquals":{"s3:ExistingObjectTag/k":"v"}}}`)
|
||||
member := iamAdminUser(ctx, t, sys, "unread-group-member", "")
|
||||
_, err := sys.AddUsersToGroup(ctx, "unread-group", []string{member.AccessKey})
|
||||
mustIAM(t, err)
|
||||
_, err = sys.PolicyDBSet(ctx, "unread-group", policyName, regUser, true)
|
||||
mustIAM(t, err)
|
||||
role, err := arn.NewIAMRoleARN("unread-role", "")
|
||||
mustIAM(t, err)
|
||||
sys.rolesMap = map[arn.ARN]string{role: policyName}
|
||||
claimUser := auth.Credentials{
|
||||
AccessKey: "unread-sts", ParentUser: "external-parent", SessionToken: "token",
|
||||
Expiration: UTCNow().Add(time.Hour), Claims: map[string]any{iamPolicyClaimNameOpenID(): policyName},
|
||||
}
|
||||
roleUser := claimUser
|
||||
roleUser.Claims = map[string]any{roleArnClaim: role.String()}
|
||||
original := sys.store.IAMStorageAPI
|
||||
defer func() { sys.store.IAMStorageAPI = original }()
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
cred auth.Credentials
|
||||
}{
|
||||
{"group", member},
|
||||
{"claims", claimUser},
|
||||
{"role", roleUser},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
name := tc.cred.AccessKey
|
||||
if tc.cred.ParentUser != "" {
|
||||
name = tc.cred.ParentUser
|
||||
}
|
||||
probe := &batchBlockedIAMStore{
|
||||
IAMStorageAPI: original, mapping: name, doc: policyName,
|
||||
started: make(chan struct{}), release: make(chan struct{}),
|
||||
}
|
||||
close(probe.release) // report an accidental read without blocking the test
|
||||
sys.store.IAMStorageAPI = probe
|
||||
sets := sys.unreadTagPolicySets(tc.cred)
|
||||
select {
|
||||
case <-probe.started:
|
||||
t.Error("candidate collection attempted a storage read")
|
||||
default:
|
||||
}
|
||||
if len(sets) != 1 || len(sets[0]) != 1 {
|
||||
t.Fatalf("expected the cached policy through %s, got %v", tc.name, sets)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+25
-13
@@ -340,16 +340,6 @@ func checkRequestAuthType(ctx context.Context, r *http.Request, action policy.Ac
|
||||
return s3Err
|
||||
}
|
||||
|
||||
func checkRequestAuthTypeWithExistingTags(ctx context.Context, r *http.Request, action policy.Action, bucketName, objectName, existingTags string) (s3Err APIErrorCode) {
|
||||
logger.GetReqInfo(ctx).BucketName = bucketName
|
||||
logger.GetReqInfo(ctx).ObjectName = objectName
|
||||
|
||||
if s3Err = authenticateRequest(ctx, r, action); s3Err != ErrNone {
|
||||
return s3Err
|
||||
}
|
||||
return authorizeRequestWithExistingTags(ctx, r, action, existingTags)
|
||||
}
|
||||
|
||||
func checkRequestAuthTypeWithRequestTags(ctx context.Context, r *http.Request, action policy.Action, bucketName, objectName string, requestTags *string) (s3Err APIErrorCode) {
|
||||
logger.GetReqInfo(ctx).BucketName = bucketName
|
||||
logger.GetReqInfo(ctx).ObjectName = objectName
|
||||
@@ -425,6 +415,19 @@ func authorizeRequest(ctx context.Context, r *http.Request, action policy.Action
|
||||
return authorizeRequestWithExistingTags(ctx, r, action, "")
|
||||
}
|
||||
|
||||
// authorizeBucketRequest authorizes a bucket action, such as s3:ListBucket,
|
||||
// on the bucket of a request made for one of its objects.
|
||||
func authorizeBucketRequest(ctx context.Context, r *http.Request, action policy.Action) APIErrorCode {
|
||||
reqInfo := logger.GetReqInfo(ctx)
|
||||
if reqInfo == nil {
|
||||
return ErrAccessDenied
|
||||
}
|
||||
objectName := reqInfo.ObjectName
|
||||
reqInfo.ObjectName = ""
|
||||
defer func() { reqInfo.ObjectName = objectName }()
|
||||
return authorizeRequest(ctx, r, action)
|
||||
}
|
||||
|
||||
func deleteObjectAction(versionID string) policy.Action {
|
||||
if versionID != "" {
|
||||
return policy.DeleteObjectVersionAction
|
||||
@@ -676,7 +679,15 @@ func setAuthMiddleware(h http.Handler) http.Handler {
|
||||
|
||||
aType := getRequestAuthType(r)
|
||||
switch aType {
|
||||
case authTypeSigned, authTypeSignedV2, authTypeStreamingSigned, authTypeStreamingSignedTrailer:
|
||||
case authTypeSigned, authTypeSignedV2, authTypeStreamingSigned, authTypeStreamingSignedTrailer, authTypeStreamingUnsignedTrailer:
|
||||
// STREAMING-UNSIGNED-PAYLOAD-TRAILER is valid for a genuinely
|
||||
// anonymous request. Once Authorization is present, it is a
|
||||
// signed REST-HEADER request and must obey the same date/skew
|
||||
// bound as the other SigV4 streaming forms.
|
||||
if aType == authTypeStreamingUnsignedTrailer && r.Header.Get(xhttp.Authorization) == "" {
|
||||
h.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
// Verify if date headers are set, if not reject the request
|
||||
amzDate, errCode := parseAmzDateHeader(r)
|
||||
if errCode != ErrNone {
|
||||
@@ -774,11 +785,12 @@ func isPutActionAllowedWithRequestTags(ctx context.Context, atype authType, buck
|
||||
|
||||
func isPutActionAllowedWithCred(bucketName, objectName string, r *http.Request, action policy.Action, requestTags *string, cred auth.Credentials, owner bool) APIErrorCode {
|
||||
// Do not check for PutObjectRetentionAction permission,
|
||||
// if mode and retain until date are not set.
|
||||
// if neither retention values nor a replica retention revision are set.
|
||||
// Can happen when bucket has default lock config set
|
||||
if action == policy.PutObjectRetentionAction &&
|
||||
r.Header.Get(xhttp.AmzObjectLockMode) == "" &&
|
||||
r.Header.Get(xhttp.AmzObjectLockRetainUntilDate) == "" {
|
||||
r.Header.Get(xhttp.AmzObjectLockRetainUntilDate) == "" &&
|
||||
r.Header.Get(xhttp.MinIOSourceObjectRetentionTimestamp) == "" {
|
||||
return ErrNone
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
// Copyright (c) 2026 PGSTY
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/minio/minio/internal/auth"
|
||||
"github.com/pgsty/silo-pkg/v3/policy"
|
||||
)
|
||||
|
||||
// batchContextRead is the object read the tests below authorize.
|
||||
func batchContextRead(f *batchAuthzFixture) batchAuthzRequest {
|
||||
return batchAuthzRequest{action: policy.GetObjectAction, bucket: f.bucket, object: "data/a"}
|
||||
}
|
||||
|
||||
// batchContextUser creates a user with statements as its only policy and
|
||||
// returns its submitter binding.
|
||||
func batchContextUser(t *testing.T, f *batchAuthzFixture, name, statements string) (auth.Credentials, *batchJobSubmitter) {
|
||||
t.Helper()
|
||||
batchAuthzPolicy(f.ctx, t, f.sys, name, statements)
|
||||
cred := iamAdminUser(f.ctx, t, f.sys, name+"-user", name)
|
||||
sub, err := newBatchJobSubmitter(cred)
|
||||
mustIAM(t, err)
|
||||
return cred, sub
|
||||
}
|
||||
|
||||
func isBatchContextRefusal(err error) bool {
|
||||
return isBatchJobAuthzError(err) && strings.Contains(err.Error(), "request context")
|
||||
}
|
||||
|
||||
// A statement whose resources rule the object out does not apply, whatever
|
||||
// its conditions need, policy variables in condition values included. One
|
||||
// whose resources match still refuses.
|
||||
func TestBatchAuthzContextResourceFirst(t *testing.T) {
|
||||
f := newBatchAuthzFixture(t)
|
||||
const unrelated = `{"Effect":"Deny","Action":["s3:GetObject"],"Resource":["arn:aws:s3:::other-bucket/*"],"Condition":{"StringEquals":{"s3:ExistingObjectTag/owner":"${aws:UserAgent}"}}}`
|
||||
_, sub := batchContextUser(t, f, "context-unrelated", batchAuthzFullAccess+",\n"+unrelated)
|
||||
if err := sub.authorize(f.ctx, batchContextRead(f)); err != nil {
|
||||
t.Errorf("a Deny on another bucket refused an allowed read: %v", err)
|
||||
}
|
||||
|
||||
related := strings.Replace(unrelated, "other-bucket", f.bucket, 1)
|
||||
_, sub = batchContextUser(t, f, "context-related", batchAuthzFullAccess+",\n"+related)
|
||||
if err := sub.authorize(f.ctx, batchContextRead(f)); !isBatchContextRefusal(err) {
|
||||
t.Errorf("a Deny on the object with an unsupplied variable must refuse, got %v", err)
|
||||
}
|
||||
|
||||
// A variable in the resource leaves open whether it matches.
|
||||
const resourceVar = `{"Effect":"Deny","Action":["s3:GetObject"],"Resource":["arn:aws:s3:::batch-authz/${aws:UserAgent}/*"]}`
|
||||
_, sub = batchContextUser(t, f, "context-resource-var", batchAuthzFullAccess+",\n"+resourceVar)
|
||||
if err := sub.authorize(f.ctx, batchContextRead(f)); !isBatchContextRefusal(err) {
|
||||
t.Errorf("a Deny whose resource holds an unsupplied variable must refuse, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// An Allow that needs request context the job does not have grants nothing
|
||||
// to the job; it neither refuses the job nor grants by an absent key.
|
||||
func TestBatchAuthzContextAllowDropped(t *testing.T) {
|
||||
f := newBatchAuthzFixture(t)
|
||||
const ipAllow = `{"Effect":"Allow","Action":["s3:GetObject"],"Resource":["arn:aws:s3:::*"],"Condition":{"IpAddress":{"aws:SourceIp":["10.0.0.0/8"]}}}`
|
||||
_, sub := batchContextUser(t, f, "context-ip-allow", batchAuthzFullAccess+",\n"+ipAllow)
|
||||
if err := sub.authorize(f.ctx, batchContextRead(f)); err != nil {
|
||||
t.Errorf("a conditional Allow refused a read another Allow grants: %v", err)
|
||||
}
|
||||
|
||||
// A Null condition holds for an absent key: evaluated without the
|
||||
// context, this Allow would grant what no request with an address gets.
|
||||
const nullAllow = `{"Effect":"Allow","Action":["admin:StartBatchJob"]},
|
||||
{"Effect":"Allow","Action":["s3:GetObject"],"Resource":["arn:aws:s3:::batch-authz/*"],"Condition":{"Null":{"aws:SourceIp":"true"}}}`
|
||||
cred, sub := batchContextUser(t, f, "context-null-allow", nullAllow)
|
||||
if !f.sys.isAllowedLocally(policy.Args{AccountName: cred.AccessKey, Action: policy.GetObjectAction, BucketName: f.bucket, ObjectName: "data/a", ConditionValues: map[string][]string{}}) {
|
||||
t.Fatal("control: without the address the Null Allow grants")
|
||||
}
|
||||
err := sub.authorize(f.ctx, batchContextRead(f))
|
||||
if !isBatchJobAuthzError(err) || isBatchContextRefusal(err) {
|
||||
t.Errorf("an Allow that needs the address must grant nothing, and deny rather than refuse; got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The same holds for the Allows of a session policy, which only narrows.
|
||||
func TestBatchAuthzContextSessionAllow(t *testing.T) {
|
||||
f := newBatchAuthzFixture(t)
|
||||
batchAuthzPolicy(f.ctx, t, f.sys, "context-session-parent", batchAuthzFullAccess)
|
||||
parent := iamAdminUser(f.ctx, t, f.sys, "context-session-parent", "context-session-parent")
|
||||
for _, tc := range []struct {
|
||||
name, statements string
|
||||
allowed bool
|
||||
}{
|
||||
{"null-only", `{"Effect":"Allow","Action":["s3:GetObject"],"Resource":["arn:aws:s3:::batch-authz/*"],"Condition":{"Null":{"aws:SourceIp":"true"}}}`, false},
|
||||
{"with-plain", `{"Effect":"Allow","Action":["s3:GetObject"],"Resource":["arn:aws:s3:::batch-authz/*"],"Condition":{"Null":{"aws:SourceIp":"true"}}},
|
||||
{"Effect":"Allow","Action":["s3:GetObject"],"Resource":["arn:aws:s3:::batch-authz/data/*"]}`, true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
sp, err := policy.ParseConfig(strings.NewReader(`{"Version":"2012-10-17","Statement":[` + tc.statements + `]}`))
|
||||
mustIAM(t, err)
|
||||
created, _, err := f.sys.NewServiceAccount(f.ctx, parent.AccessKey, nil, newServiceAccountOpts{sessionPolicy: sp})
|
||||
mustIAM(t, err)
|
||||
stored, ok := f.sys.GetUser(f.ctx, created.AccessKey)
|
||||
if !ok {
|
||||
t.Fatal("service account missing")
|
||||
}
|
||||
sub, err := newBatchJobSubmitter(stored.Credentials)
|
||||
mustIAM(t, err)
|
||||
err = sub.authorize(f.ctx, batchContextRead(f))
|
||||
switch {
|
||||
case isBatchContextRefusal(err):
|
||||
t.Errorf("a session-policy Allow refused the job: %v", err)
|
||||
case tc.allowed && err != nil:
|
||||
t.Errorf("the plain session Allow must still grant: %v", err)
|
||||
case !tc.allowed && !isBatchJobAuthzError(err):
|
||||
t.Errorf("a session policy whose only Allow needs the address must deny, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -330,7 +330,7 @@ func TestBatchReviewRotationKeepsMigrationCleanup(t *testing.T) {
|
||||
t.Errorf("rotation changed a draining source after its version was copied (rotation error: %v)", rotationErr)
|
||||
}
|
||||
snapshot := []FileInfo{{VersionID: before.VersionID, ModTime: beforeInfo.ModTime}}
|
||||
if err := z.removeMovedVersions(t.Context(), bucket, object, 0, snapshot, nil); err != nil {
|
||||
if _, err := z.removeMovedVersions(t.Context(), bucket, object, 0, snapshot, nil); err != nil {
|
||||
t.Fatalf("rotation prevented migration cleanup: %v", err)
|
||||
}
|
||||
if _, err := z.serverPools[0].GetObjectInfo(t.Context(), bucket, object, ObjectOptions{VersionID: before.VersionID}); !isErrObjectNotFound(err) && !isErrVersionNotFound(err) {
|
||||
|
||||
+141
-55
@@ -34,12 +34,17 @@ import (
|
||||
// (batchJobSubmitter). Nothing in the submitted definition names it.
|
||||
// - Every start of the job, and every object it touches, resolves the
|
||||
// binding against IAM again. A credential that is gone, disabled,
|
||||
// expired, revoked or edited since submission fails the job; a reused
|
||||
// access key does not match the binding.
|
||||
// expired or revoked fails the job, and so does one whose secret,
|
||||
// session token or parent changed since submission; a reused access key
|
||||
// does not match the binding. Edits that leave the credential itself as
|
||||
// it was (a name, a description, a later expiry of an expiring
|
||||
// credential, the LDAP groups) keep the job: its policies, groups,
|
||||
// status and expiry are read again each time.
|
||||
// - Each action is evaluated with the submitter's policies and session
|
||||
// policy but without a request. A policy statement that would need
|
||||
// policy but without a request. A Deny that may apply and would need
|
||||
// request context the job does not have (source address, transport,
|
||||
// headers and so on) refuses the action instead of being skipped.
|
||||
// headers and so on) refuses the action instead of being skipped; an
|
||||
// Allow that would need it grants nothing to the job.
|
||||
//
|
||||
// A job without a binding was queued by an earlier release. It is never
|
||||
// authorized, whoever submitted it, and has to be submitted again.
|
||||
@@ -54,8 +59,13 @@ type batchJobSubmitter struct {
|
||||
AccessKey string `msg:"ak"`
|
||||
// ParentUser is the parent of a derived credential.
|
||||
ParentUser string `msg:"pu"`
|
||||
// UpdatedAt and RevokedBefore are the revision of the identity record;
|
||||
// any edit of the identity changes them. They are unset for root.
|
||||
// UpdatedAt is the revision of the identity record at submission. It is
|
||||
// recorded for diagnosis only: an edit that keeps the credential, such as
|
||||
// a description, a later expiry or an LDAP group refresh, changes it and
|
||||
// must not end the job. RevokedBefore is the revocation boundary of the
|
||||
// record, which edits keep and a deletion advances, so a credential
|
||||
// created again under the access key does not match. Both are unset for
|
||||
// root.
|
||||
UpdatedAt time.Time `msg:"ua"`
|
||||
RevokedBefore time.Time `msg:"rb"`
|
||||
// Binding is a keyed fingerprint of the credential itself, see
|
||||
@@ -228,8 +238,13 @@ func (s *batchJobSubmitter) principal(ctx context.Context) (batchJobPrincipal, e
|
||||
}
|
||||
cred := u.Credentials
|
||||
typ := batchSubmitterType(cred)
|
||||
// The binding covers the secret, the session token and the parent, so a
|
||||
// rotated or reissued credential does not match it. The rest of the
|
||||
// identity record (UpdatedAt) changes with edits that keep the
|
||||
// credential and is not compared; what those edits decide, status,
|
||||
// expiry, groups and policies, is evaluated below and per action.
|
||||
if typ != s.Type || cred.AccessKey != s.AccessKey || cred.ParentUser != s.ParentUser ||
|
||||
!u.UpdatedAt.Equal(s.UpdatedAt) || !u.RevokedBefore.Equal(s.RevokedBefore) ||
|
||||
!u.RevokedBefore.Equal(s.RevokedBefore) ||
|
||||
!hmac.Equal(s.Binding, batchSubmitterBinding(typ, cred)) {
|
||||
return batchJobPrincipal{}, errBatchJobChanged
|
||||
}
|
||||
@@ -474,22 +489,36 @@ func (p batchJobPrincipal) authorizeArgs(args policy.Args, req *batchAuthzReques
|
||||
snap = globalIAMSys.store.batchSnapshot(ctx)
|
||||
defer snap.release()
|
||||
}
|
||||
policies, err := globalIAMSys.batchLocalPolicies(p, args, plugin == nil, snap)
|
||||
mapped, session, err := globalIAMSys.batchLocalPolicies(p, args, plugin == nil, snap)
|
||||
if err != nil {
|
||||
if errors.Is(err, errNoSuchPolicy) || errors.Is(err, errAuthentication) || errors.Is(err, errNoSuchUser) || errors.Is(err, errNoSuchGroup) {
|
||||
return denied
|
||||
}
|
||||
return batchUnavailable("IAM policy lookup", err)
|
||||
}
|
||||
// sessionNarrowed: the session policy lost an Allow below, so the
|
||||
// narrowed policy decides in its place.
|
||||
sessionNarrowed := false
|
||||
if req != nil {
|
||||
supplied := req.suppliedConditionKeys()
|
||||
for _, pol := range policies {
|
||||
for _, st := range pol.Statements {
|
||||
if batchStatementNeedsRequest(st, args, supplied) {
|
||||
var refuse bool
|
||||
if mapped, _, refuse = batchContextFree(mapped, args, supplied); !refuse {
|
||||
session, sessionNarrowed, refuse = batchContextFree(session, args, supplied)
|
||||
}
|
||||
if refuse {
|
||||
return batchJobAuthzError{reason: denied.reason + ": a policy statement depends on request context that a batch job does not have"}
|
||||
}
|
||||
}
|
||||
sessionDenies := func() bool {
|
||||
has, allowed := p.sessionPolicyDecision(args)
|
||||
if has && allowed && sessionNarrowed {
|
||||
// As sessionPolicyDecision evaluates it, without its dropped
|
||||
// Allows. This can only turn an allow into a deny.
|
||||
spArgs := args
|
||||
spArgs.IsOwner, spArgs.DenyOnly = false, false
|
||||
allowed = session.IsAllowed(spArgs)
|
||||
}
|
||||
return has && !allowed
|
||||
}
|
||||
if plugin != nil {
|
||||
ok, err := plugin.IsAllowedContext(ctx, args)
|
||||
@@ -500,7 +529,7 @@ func (p batchJobPrincipal) authorizeArgs(args policy.Args, req *batchAuthzReques
|
||||
return denied
|
||||
}
|
||||
// The plugin does not see a session policy; it only limits further.
|
||||
if has, allowed := p.sessionPolicyDecision(args); has && !allowed {
|
||||
if sessionDenies() {
|
||||
return denied
|
||||
}
|
||||
return nil
|
||||
@@ -516,7 +545,7 @@ func (p batchJobPrincipal) authorizeArgs(args policy.Args, req *batchAuthzReques
|
||||
return denied
|
||||
}
|
||||
}
|
||||
if has, allowed := p.sessionPolicyDecision(args); has && !allowed {
|
||||
if sessionDenies() {
|
||||
return denied
|
||||
}
|
||||
ownerDerived := (p.temp || p.service) && p.parentUser == globalActiveCred.AccessKey
|
||||
@@ -524,13 +553,50 @@ func (p batchJobPrincipal) authorizeArgs(args policy.Args, req *batchAuthzReques
|
||||
if p.service {
|
||||
args.AccountName = p.parentUser
|
||||
}
|
||||
if len(policies) == 0 || !policies[0].IsAllowed(args) {
|
||||
if mapped == nil || !mapped.IsAllowed(args) {
|
||||
return denied
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// batchContextFree returns pol without the Allow statements that may apply
|
||||
// to args but need request context the job does not supply (see
|
||||
// batchStatementNeedsRequest), and whether it dropped any. Such an Allow
|
||||
// grants nothing to the job. It is dropped rather than evaluated, because
|
||||
// evaluated without the context it could grant what a request would not:
|
||||
// IfExists and Null conditions hold for an absent key. Dropping an Allow can
|
||||
// only narrow the decision. Dropping a Deny that needs the context could
|
||||
// allow what a request with that context would not, so refuse reports one,
|
||||
// and the job refuses the action.
|
||||
func batchContextFree(pol *policy.Policy, args policy.Args, supplied map[string]bool) (out *policy.Policy, dropped, refuse bool) {
|
||||
if pol == nil {
|
||||
return nil, false, false
|
||||
}
|
||||
var kept []policy.Statement
|
||||
for i, st := range pol.Statements {
|
||||
if !batchStatementNeedsRequest(st, args, supplied) {
|
||||
if dropped {
|
||||
kept = append(kept, st)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if st.Effect != policy.Allow {
|
||||
return pol, false, true
|
||||
}
|
||||
if !dropped {
|
||||
kept = append(make([]policy.Statement, 0, len(pol.Statements)-1), pol.Statements[:i]...)
|
||||
dropped = true
|
||||
}
|
||||
}
|
||||
if !dropped {
|
||||
return pol, false, false
|
||||
}
|
||||
// A new policy: pol's statement index refers to pol's own statements, and
|
||||
// pol may be a cached policy document.
|
||||
return &policy.Policy{ID: pol.ID, Version: pol.Version, Statements: kept}, true, false
|
||||
}
|
||||
|
||||
func batchDeniedReason(req batchAuthzRequest) string {
|
||||
resource := req.bucket
|
||||
if req.object != "" {
|
||||
@@ -636,13 +702,13 @@ func (s *batchPolicySnapshot) loadPolicyDoc(name string, out map[string]PolicyDo
|
||||
}
|
||||
|
||||
// batchLocalPolicies returns every local policy that can take part in the
|
||||
// decision on args: the mapped policies that IsAllowed would merge when
|
||||
// withMapped is set (no authorization plugin), and the session policy.
|
||||
func (sys *IAMSys) batchLocalPolicies(p batchJobPrincipal, args policy.Args, withMapped bool, snap *batchPolicySnapshot) ([]policy.Policy, error) {
|
||||
var out []policy.Policy
|
||||
// decision on args: mapped, the mapped policies merged as IsAllowed would
|
||||
// merge them, when withMapped is set (no authorization plugin), and session,
|
||||
// the session policy. Each is nil when it takes no part.
|
||||
func (sys *IAMSys) batchLocalPolicies(p batchJobPrincipal, args policy.Args, withMapped bool, snap *batchPolicySnapshot) (mapped, session *policy.Policy, err error) {
|
||||
if withMapped && args.IsOwner {
|
||||
// IsAllowed does not evaluate any policy for the owner.
|
||||
return nil, nil
|
||||
return nil, nil, nil
|
||||
}
|
||||
if withMapped {
|
||||
// The caller holds snap through evaluation. Lazy mapping loads and
|
||||
@@ -664,28 +730,28 @@ func (sys *IAMSys) batchLocalPolicies(p batchJobPrincipal, args policy.Args, wit
|
||||
if roleArn := args.GetRoleArn(); roleArn != "" {
|
||||
a, err := arn.Parse(roleArn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
names = append(names, newMappedPolicy(sys.rolesMap[a]).toSlice()...)
|
||||
} else {
|
||||
mapped, err := mappedPolicies(p.parentUser)
|
||||
parent, err := mappedPolicies(p.parentUser)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
names = append(names, mapped...)
|
||||
names = append(names, parent...)
|
||||
// The claimed policies apply only when nothing is mapped.
|
||||
if len(mapped) == 0 {
|
||||
if len(parent) == 0 {
|
||||
if claimed, ok := policy.GetPoliciesFromClaims(args.Claims, iamPolicyClaimNameOpenID()); ok {
|
||||
names = append(names, claimed.ToSlice()...)
|
||||
}
|
||||
}
|
||||
}
|
||||
default:
|
||||
mapped, err := mappedPolicies(p.accessKey)
|
||||
own, err := mappedPolicies(p.accessKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
names = append(names, mapped...)
|
||||
names = append(names, own...)
|
||||
}
|
||||
var selected []policy.Policy
|
||||
for _, name := range names {
|
||||
@@ -700,63 +766,56 @@ func (sys *IAMSys) batchLocalPolicies(p batchJobPrincipal, args policy.Args, wit
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
doc, ok = loaded[name]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("load IAM policy %q returned no document", name)
|
||||
return nil, nil, fmt.Errorf("load IAM policy %q returned no document", name)
|
||||
}
|
||||
}
|
||||
selected = append(selected, doc.Policy)
|
||||
}
|
||||
out = append(out, policy.MergePolicies(selected...))
|
||||
merged := policy.MergePolicies(selected...)
|
||||
mapped = &merged
|
||||
}
|
||||
if p.temp || p.service {
|
||||
if p.service && p.claims[iamPolicyClaimNameSA()] == inheritedPolicyType {
|
||||
return out, nil
|
||||
return mapped, nil, nil
|
||||
}
|
||||
if sp, ok := p.claims[sessionPolicyNameExtracted]; ok {
|
||||
s, ok := sp.(string)
|
||||
if !ok {
|
||||
return nil, errAuthentication
|
||||
return nil, nil, errAuthentication
|
||||
}
|
||||
parsed, err := policy.ParseConfig(strings.NewReader(s))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
out = append(out, *parsed)
|
||||
session = parsed
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
return mapped, session, nil
|
||||
}
|
||||
|
||||
// batchStatementNeedsRequest reports whether a statement that applies to
|
||||
// args depends on a condition key that the job does not supply. Applying
|
||||
// batchStatementNeedsRequest reports whether a statement may apply to args
|
||||
// and depends on request context that the job does not supply: a condition
|
||||
// key, or a policy variable in a resource or a condition value. Applying
|
||||
// such a statement without its value could grant (Allow) or drop (Deny)
|
||||
// what a request with that context would not, so the job refuses the action.
|
||||
// Resources are matched without conditions; a statement is taken to apply
|
||||
// whenever it might.
|
||||
// what a request with that context would not (see batchContextFree).
|
||||
//
|
||||
// Resources are matched first and without conditions; a statement is taken
|
||||
// to apply whenever it might. A statement whose resources rule args out
|
||||
// does not apply, whatever its conditions need. One whose resources hold a
|
||||
// variable the job does not supply might match, so it may apply.
|
||||
func batchStatementNeedsRequest(st policy.Statement, args policy.Args, supplied map[string]bool) bool {
|
||||
if (!st.Actions.Match(args.Action) && !st.Actions.IsEmpty()) || st.NotActions.Match(args.Action) {
|
||||
return false
|
||||
}
|
||||
// Policy variables in resources or condition values.
|
||||
if data, err := json.Marshal(st); err == nil {
|
||||
s := string(data)
|
||||
for {
|
||||
i := strings.Index(s, "${")
|
||||
if i < 0 {
|
||||
break
|
||||
}
|
||||
s = s[i+2:]
|
||||
j := strings.IndexByte(s, '}')
|
||||
if j < 0 {
|
||||
break
|
||||
}
|
||||
if !batchKeySupplied(supplied, s[:j]) {
|
||||
for _, resources := range []policy.ResourceSet{st.Resources, st.NotResources} {
|
||||
for r := range resources {
|
||||
if batchUnsuppliedVariable(r.Pattern, supplied) {
|
||||
return true
|
||||
}
|
||||
s = s[j+1:]
|
||||
}
|
||||
}
|
||||
if !batchStatementMayApply(st, args) {
|
||||
@@ -767,9 +826,36 @@ func batchStatementNeedsRequest(st policy.Statement, args policy.Args, supplied
|
||||
return true
|
||||
}
|
||||
}
|
||||
// Policy variables in condition values.
|
||||
if len(st.Conditions) > 0 {
|
||||
data, err := json.Marshal(st.Conditions)
|
||||
if err != nil || batchUnsuppliedVariable(string(data), supplied) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// batchUnsuppliedVariable reports whether s holds a policy variable, ${key},
|
||||
// whose value the job does not supply.
|
||||
func batchUnsuppliedVariable(s string, supplied map[string]bool) bool {
|
||||
for {
|
||||
i := strings.Index(s, "${")
|
||||
if i < 0 {
|
||||
return false
|
||||
}
|
||||
s = s[i+2:]
|
||||
j := strings.IndexByte(s, '}')
|
||||
if j < 0 {
|
||||
return false
|
||||
}
|
||||
if !batchKeySupplied(supplied, s[:j]) {
|
||||
return true
|
||||
}
|
||||
s = s[j+1:]
|
||||
}
|
||||
}
|
||||
|
||||
// batchStatementMayApply matches the resources of a statement, in both forms
|
||||
// a bucket request can take. A statement whose resources the evaluator
|
||||
// translates (S3 Tables and vectors actions cover S3 data actions) is taken
|
||||
|
||||
@@ -196,11 +196,23 @@ func TestBatchExpirePurgeRefusesLockedAfterWalk(t *testing.T) {
|
||||
func (f *batchAuthzFixture) transition(t *testing.T, key, versionID string) {
|
||||
t.Helper()
|
||||
set := f.obj.(*erasureServerPools).serverPools[0]
|
||||
infos, errs := readAllFileInfo(f.ctx, set.getHashedSet(key).getDisks(), "", f.bucket, key, versionID, false, false)
|
||||
er := set.getHashedSet(key)
|
||||
// DecomTieredObject takes its parity from the global storage class, which
|
||||
// earlier tests can leave at a parity this set cannot have; it then
|
||||
// writes on no drive and still succeeds. Use the set's own parity; the
|
||||
// whole configuration is restored when the test ends.
|
||||
setTestStandardParity(t, er.defaultParityCount)
|
||||
infos, errs := readAllFileInfo(f.ctx, er.getDisks(), "", f.bucket, key, versionID, false, false)
|
||||
mustIAM(t, errs[0])
|
||||
fi := infos[0]
|
||||
fi.TransitionStatus, fi.TransitionTier, fi.TransitionedObjName = lifecycle.TransitionComplete, "WARM", "remote/"+key+"/"+versionID
|
||||
mustIAM(t, set.DecomTieredObject(f.ctx, f.bucket, key, fi, ObjectOptions{}))
|
||||
infos, errs = readAllFileInfo(f.ctx, er.getDisks(), "", f.bucket, key, versionID, false, false)
|
||||
for i := range infos {
|
||||
if errs[i] != nil || infos[i].TransitionStatus != lifecycle.TransitionComplete {
|
||||
t.Fatalf("drive %d does not hold the transition: %v", i, errs[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A key with a transitioned version is refused as a whole, as a key with a
|
||||
|
||||
+76
-38
@@ -462,6 +462,7 @@ func (r *BatchJobReplicateV1) StartFromSource(ctx context.Context, api ObjectLay
|
||||
if done, err := ri.startAuthorized(ctx, api, job); done || err != nil {
|
||||
return err
|
||||
}
|
||||
ri.resetReplicationAttempt()
|
||||
globalBatchJobsMetrics.save(job.ID, ri)
|
||||
|
||||
retryAttempts := job.Replicate.Flags.Retry.Attempts
|
||||
@@ -576,9 +577,10 @@ func (r *BatchJobReplicateV1) StartFromSource(ctx context.Context, api ObjectLay
|
||||
s3Type := r.Target.Type == BatchJobReplicateResourceS3 || r.Source.Type == BatchJobReplicateResourceS3
|
||||
minioSrc := r.Source.Type == BatchJobReplicateResourceMinIO
|
||||
ctx, cancelCause := context.WithCancelCause(ctx)
|
||||
// refuse ends the job for its submitter: record why before stopping.
|
||||
refuse := func(err error) {
|
||||
stop := func(err error) {
|
||||
if isBatchJobAuthzError(err) {
|
||||
ri.refuse(err)
|
||||
}
|
||||
cancelCause(err)
|
||||
}
|
||||
// Each run lists the source from the start and counts every locked
|
||||
@@ -615,6 +617,15 @@ func (r *BatchJobReplicateV1) StartFromSource(ctx context.Context, api ObjectLay
|
||||
// Refused or canceled: schedule nothing more.
|
||||
continue
|
||||
}
|
||||
if obj.Err != nil {
|
||||
switch minio.ToErrorResponse(obj.Err).StatusCode {
|
||||
case http.StatusUnauthorized, http.StatusForbidden:
|
||||
stop(batchJobAuthzError{reason: "source listing denied: " + obj.Err.Error()})
|
||||
default:
|
||||
stop(batchListingError(obj.Err))
|
||||
}
|
||||
continue
|
||||
}
|
||||
oi := toObjectInfo(r.Source.Bucket, obj.Key, obj)
|
||||
if !minioSrc {
|
||||
// Check if metadata filter was requested and it is expected to have
|
||||
@@ -663,8 +674,11 @@ func (r *BatchJobReplicateV1) StartFromSource(ctx context.Context, api ObjectLay
|
||||
stopFn := globalBatchJobsMetrics.trace(batchJobMetricReplication, job.ID, attempts)
|
||||
success := true
|
||||
if err := r.ReplicateFromSource(ctx, api, core, oi, retry, job.Submitter); err != nil {
|
||||
if isBatchJobAuthzError(err) {
|
||||
refuse(err)
|
||||
if isBatchJobStopped(err) {
|
||||
stop(err)
|
||||
return
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
// A locked destination version is left as it is: the
|
||||
@@ -706,6 +720,14 @@ func (r *BatchJobReplicateV1) StartFromSource(ctx context.Context, api ObjectLay
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if cause := context.Cause(ctx); cause != nil {
|
||||
cancelCause(nil)
|
||||
if isBatchJobUnavailable(cause) {
|
||||
ri.resetReplicationAttempt()
|
||||
return ri.savePaused(ctx, api, job, cause)
|
||||
}
|
||||
return cause
|
||||
}
|
||||
|
||||
ri.RetryAttempts = attempts
|
||||
ri.Complete = ri.ObjectsFailed == 0 && ri.DeleteMarkersFailed == 0
|
||||
@@ -721,14 +743,7 @@ func (r *BatchJobReplicateV1) StartFromSource(ctx context.Context, api ObjectLay
|
||||
|
||||
cancelCause(nil)
|
||||
if ri.Failed {
|
||||
ri.ObjectsFailed = 0
|
||||
ri.DeleteMarkersFailed = 0
|
||||
ri.Bucket = ""
|
||||
ri.Object = ""
|
||||
ri.Objects = 0
|
||||
ri.DeleteMarkers = 0
|
||||
ri.BytesFailed = 0
|
||||
ri.BytesTransferred = 0
|
||||
ri.resetReplicationAttempt()
|
||||
retry = true // indicate we are retrying..
|
||||
time.Sleep(delay + time.Duration(rnd.Float64()*float64(delay)))
|
||||
continue
|
||||
@@ -1337,6 +1352,21 @@ func (ri *batchJobInfo) trackCurrentBucketBatch(bucket string, batch []ObjectInf
|
||||
}
|
||||
}
|
||||
|
||||
func (ri *batchJobInfo) resetReplicationAttempt() {
|
||||
ri.mu.Lock()
|
||||
defer ri.mu.Unlock()
|
||||
ri.Complete, ri.Failed = false, false
|
||||
ri.ObjectsFailed = 0
|
||||
ri.DeleteMarkersFailed = 0
|
||||
ri.Bucket = ""
|
||||
ri.Object = ""
|
||||
ri.Objects = 0
|
||||
ri.DeleteMarkers = 0
|
||||
ri.BytesFailed = 0
|
||||
ri.BytesTransferred = 0
|
||||
ri.VersionsSkippedLocked = 0
|
||||
}
|
||||
|
||||
// Start start the batch replication job, resumes if there was a pending job via "job.ID"
|
||||
func (r *BatchJobReplicateV1) Start(ctx context.Context, api ObjectLayer, job BatchJobRequest) error {
|
||||
ri := &batchJobInfo{
|
||||
@@ -1353,8 +1383,8 @@ func (r *BatchJobReplicateV1) Start(ctx context.Context, api ObjectLayer, job Ba
|
||||
if done, err := ri.startAuthorized(ctx, api, job); done || err != nil {
|
||||
return err
|
||||
}
|
||||
ri.resetReplicationAttempt()
|
||||
globalBatchJobsMetrics.save(job.ID, ri)
|
||||
lastObject := ri.Object
|
||||
|
||||
retryAttempts := job.Replicate.Flags.Retry.Attempts
|
||||
if retryAttempts <= 0 {
|
||||
@@ -1461,9 +1491,10 @@ func (r *BatchJobReplicateV1) Start(ctx context.Context, api ObjectLayer, job Ba
|
||||
slowCh = make(chan itemOrErr[ObjectInfo], 100)
|
||||
)
|
||||
ctx, cancelCause := context.WithCancelCause(ctx)
|
||||
// refuse ends the job for its submitter: record why before stopping.
|
||||
refuse := func(err error) {
|
||||
stop := func(err error) {
|
||||
if isBatchJobAuthzError(err) {
|
||||
ri.refuse(err)
|
||||
}
|
||||
cancelCause(err)
|
||||
}
|
||||
|
||||
@@ -1488,8 +1519,11 @@ func (r *BatchJobReplicateV1) Start(ctx context.Context, api ObjectLayer, job Ba
|
||||
writeFn := func(batch []ObjectInfo) {
|
||||
if len(batch) > 0 {
|
||||
if err := r.writeAsArchive(ctx, api, cl, batch, r.Target.Prefix, job.Submitter); err != nil {
|
||||
if isBatchJobAuthzError(err) {
|
||||
refuse(err)
|
||||
if isBatchJobStopped(err) {
|
||||
stop(err)
|
||||
return
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
batchLogOnceIf(ctx, err, job.ID+"writeAsArchive")
|
||||
@@ -1505,7 +1539,10 @@ func (r *BatchJobReplicateV1) Start(ctx context.Context, api ObjectLayer, job Ba
|
||||
}
|
||||
}
|
||||
for obj := range walkCh {
|
||||
if obj.Item.DeleteMarker || !obj.Item.VersionPurgeStatus.Empty() || obj.Item.Size >= int64(smallerThan) {
|
||||
if context.Cause(ctx) != nil {
|
||||
continue
|
||||
}
|
||||
if obj.Err != nil || obj.Item.DeleteMarker || !obj.Item.VersionPurgeStatus.Empty() || obj.Item.Size >= int64(smallerThan) {
|
||||
slowCh <- obj
|
||||
continue
|
||||
}
|
||||
@@ -1518,7 +1555,9 @@ func (r *BatchJobReplicateV1) Start(ctx context.Context, api ObjectLayer, job Ba
|
||||
writeFn(batch)
|
||||
batch = batch[:0]
|
||||
}
|
||||
if context.Cause(ctx) == nil {
|
||||
writeFn(batch)
|
||||
}
|
||||
xioutil.SafeClose(slowCh)
|
||||
}()
|
||||
} else {
|
||||
@@ -1551,11 +1590,10 @@ func (r *BatchJobReplicateV1) Start(ctx context.Context, api ObjectLayer, job Ba
|
||||
for _, prefix := range prefixes {
|
||||
prefixWalkCh := make(chan itemOrErr[ObjectInfo], 100)
|
||||
if err := api.Walk(ctx, r.Source.Bucket, prefix, prefixWalkCh, WalkOptions{
|
||||
Marker: lastObject,
|
||||
Filter: selectObj,
|
||||
AskDisks: walkQuorum,
|
||||
}); err != nil {
|
||||
cancelCause(err)
|
||||
cancelCause(batchListingError(err))
|
||||
xioutil.SafeClose(walkCh)
|
||||
return
|
||||
}
|
||||
@@ -1575,7 +1613,7 @@ func (r *BatchJobReplicateV1) Start(ctx context.Context, api ObjectLayer, job Ba
|
||||
continue
|
||||
}
|
||||
if res.Err != nil {
|
||||
ri.Failed = true
|
||||
cancelCause(batchListingError(res.Err))
|
||||
batchLogOnceIf(ctx, res.Err, job.ID+"res.Err")
|
||||
continue
|
||||
}
|
||||
@@ -1594,8 +1632,11 @@ func (r *BatchJobReplicateV1) Start(ctx context.Context, api ObjectLayer, job Ba
|
||||
stopFn := globalBatchJobsMetrics.trace(batchJobMetricReplication, job.ID, attempts)
|
||||
success := true
|
||||
if err := r.ReplicateToTarget(ctx, api, c, result, retry, job.Submitter); err != nil {
|
||||
if isBatchJobAuthzError(err) {
|
||||
refuse(err)
|
||||
if isBatchJobStopped(err) {
|
||||
stop(err)
|
||||
return
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if minio.ToErrorResponse(err).Code == "PreconditionFailed" {
|
||||
@@ -1632,9 +1673,13 @@ func (r *BatchJobReplicateV1) Start(ctx context.Context, api ObjectLayer, job Ba
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// Do not need to retry if we can't list objects on source.
|
||||
if context.Cause(ctx) != nil {
|
||||
return context.Cause(ctx)
|
||||
if cause := context.Cause(ctx); cause != nil {
|
||||
cancelCause(nil)
|
||||
if isBatchJobUnavailable(cause) {
|
||||
ri.resetReplicationAttempt()
|
||||
return ri.savePaused(ctx, api, job, cause)
|
||||
}
|
||||
return cause
|
||||
}
|
||||
ri.RetryAttempts = attempts
|
||||
ri.Complete = ri.ObjectsFailed == 0 && ri.DeleteMarkersFailed == 0
|
||||
@@ -1650,14 +1695,7 @@ func (r *BatchJobReplicateV1) Start(ctx context.Context, api ObjectLayer, job Ba
|
||||
|
||||
cancelCause(nil)
|
||||
if ri.Failed {
|
||||
ri.ObjectsFailed = 0
|
||||
ri.DeleteMarkersFailed = 0
|
||||
ri.Bucket = ""
|
||||
ri.Object = ""
|
||||
ri.Objects = 0
|
||||
ri.DeleteMarkers = 0
|
||||
ri.BytesFailed = 0
|
||||
ri.BytesTransferred = 0
|
||||
ri.resetReplicationAttempt()
|
||||
retry = true // indicate we are retrying..
|
||||
time.Sleep(delay + time.Duration(rnd.Float64()*float64(delay)))
|
||||
continue
|
||||
@@ -2627,20 +2665,20 @@ func (ri *batchJobInfo) metric() madmin.JobMetric {
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *batchJobMetrics) report(jobID string) (metrics *madmin.BatchJobMetrics) {
|
||||
metrics = &madmin.BatchJobMetrics{CollectedAt: time.Now(), Jobs: make(map[string]madmin.JobMetric)}
|
||||
func (m *batchJobMetrics) report(jobID string) (metrics *batchJobMetricsReport) {
|
||||
metrics = &batchJobMetricsReport{CollectedAt: time.Now(), Jobs: make(map[string]batchJobStatusMetric)}
|
||||
m.RLock()
|
||||
defer m.RUnlock()
|
||||
|
||||
if jobID != "" {
|
||||
if job, ok := m.metrics[jobID]; ok {
|
||||
metrics.Jobs[jobID] = job.metric()
|
||||
metrics.Jobs[jobID] = job.statusMetric()
|
||||
}
|
||||
return metrics
|
||||
}
|
||||
|
||||
for id, job := range m.metrics {
|
||||
metrics.Jobs[id] = job.metric()
|
||||
metrics.Jobs[id] = job.statusMetric()
|
||||
}
|
||||
return metrics
|
||||
}
|
||||
|
||||
@@ -170,7 +170,7 @@ type batchJobStatus struct {
|
||||
|
||||
type batchJobStatusMetric struct {
|
||||
madmin.JobMetric
|
||||
VersionsSkippedLocked int64 `json:"versionsSkippedLocked,omitempty"`
|
||||
VersionsSkippedLocked *int64 `json:"versionsSkippedLocked,omitempty"`
|
||||
KeysRefused int64 `json:"keysRefused,omitempty"`
|
||||
FailureReason string `json:"failureReason,omitempty"`
|
||||
PausedReason string `json:"pausedReason,omitempty"`
|
||||
@@ -179,9 +179,10 @@ type batchJobStatusMetric struct {
|
||||
func (ri *batchJobInfo) statusMetric() batchJobStatusMetric {
|
||||
ri.mu.RLock()
|
||||
defer ri.mu.RUnlock()
|
||||
versionsSkipped := ri.VersionsSkippedLocked
|
||||
return batchJobStatusMetric{
|
||||
JobMetric: ri.metric(),
|
||||
VersionsSkippedLocked: ri.VersionsSkippedLocked,
|
||||
VersionsSkippedLocked: &versionsSkipped,
|
||||
KeysRefused: ri.KeysRefused,
|
||||
FailureReason: ri.FailureReason,
|
||||
PausedReason: ri.PausedReason,
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
polplugin "github.com/minio/minio/internal/config/policy/plugin"
|
||||
xnet "github.com/pgsty/silo-pkg/v3/net"
|
||||
"github.com/pgsty/silo-pkg/v3/policy"
|
||||
)
|
||||
|
||||
type batchReplicationWriteObserver struct {
|
||||
ObjectLayer
|
||||
written func(string, string)
|
||||
}
|
||||
|
||||
type batchReplicationWalkFailure struct {
|
||||
ObjectLayer
|
||||
streamed bool
|
||||
}
|
||||
|
||||
func (failure batchReplicationWalkFailure) Walk(ctx context.Context, _, _ string, results chan<- itemOrErr[ObjectInfo], _ WalkOptions) error {
|
||||
if !failure.streamed {
|
||||
return errErasureReadQuorum
|
||||
}
|
||||
go func() {
|
||||
defer close(results)
|
||||
select {
|
||||
case results <- itemOrErr[ObjectInfo]{Err: errErasureReadQuorum}:
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestBatchReplicationListingUnavailable(t *testing.T) {
|
||||
for _, snowball := range []bool{false, true} {
|
||||
for _, streamed := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("snowball=%t/streamed=%t", snowball, streamed), func(t *testing.T) {
|
||||
fixture := newBatchAuthzFixture(t)
|
||||
job := BatchJobRequest{
|
||||
ID: "replicate-listing" + getKeySeparator() + "0", Started: UTCNow(), Submitter: batchRootSubmitter(t),
|
||||
Replicate: &BatchJobReplicateV1{
|
||||
Source: BatchJobReplicateSource{
|
||||
Type: BatchJobReplicateResourceMinIO, Bucket: fixture.bucket,
|
||||
Snowball: BatchJobSnowball{
|
||||
Disable: ptr(!snowball), InMemory: ptr(true), Compress: ptr(false), SkipErrs: ptr(false),
|
||||
Batch: ptr(1), SmallerThan: ptr("1MiB"),
|
||||
},
|
||||
},
|
||||
Target: BatchJobReplicateTarget{Type: BatchJobReplicateResourceMinIO, Bucket: "remote", Endpoint: "https://target.invalid"},
|
||||
},
|
||||
}
|
||||
api := batchReplicationWalkFailure{ObjectLayer: fixture.obj, streamed: streamed}
|
||||
if err := job.Replicate.Start(fixture.ctx, api, job); !isBatchJobUnavailable(err) {
|
||||
t.Fatalf("listing failure did not pause: %v", err)
|
||||
}
|
||||
report := fixture.jobInfo(t, &job)
|
||||
if report.PausedReason == "" || report.Failed || report.Complete || report.Object != "" || report.ObjectsFailed != 0 {
|
||||
t.Fatalf("listing failure saved an invalid outcome: %+v", report)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (observer batchReplicationWriteObserver) PutObject(ctx context.Context, bucket, object string, reader *PutObjReader, opts ObjectOptions) (ObjectInfo, error) {
|
||||
info, err := observer.ObjectLayer.PutObject(ctx, bucket, object, reader, opts)
|
||||
if err == nil {
|
||||
observer.written(bucket, object)
|
||||
}
|
||||
return info, err
|
||||
}
|
||||
|
||||
func TestBatchReplicationRemoteListingDenied(t *testing.T) {
|
||||
fixture := newBatchAuthzFixture(t)
|
||||
previousTransport := remoteInstanceTransport
|
||||
remoteInstanceTransport = atomic.Value{}
|
||||
setRemoteInstanceTransport(batchLockTransport(func(request *http.Request) (*http.Response, error) {
|
||||
status := http.StatusForbidden
|
||||
body := `<Error><Code>AccessDenied</Code><Message>source listing denied</Message></Error>`
|
||||
if request.URL.Query().Has("location") {
|
||||
status = http.StatusOK
|
||||
body = `<LocationConstraint xmlns="http://s3.amazonaws.com/doc/2006-03-01/">us-east-1</LocationConstraint>`
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: status, Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(body)), ContentLength: int64(len(body)), Request: request,
|
||||
}, nil
|
||||
}))
|
||||
defer func() { remoteInstanceTransport = previousTransport }()
|
||||
job := BatchJobRequest{
|
||||
ID: "replicate-listing-denied" + getKeySeparator() + "0", Started: UTCNow(), Submitter: batchRootSubmitter(t),
|
||||
Replicate: &BatchJobReplicateV1{
|
||||
Source: BatchJobReplicateSource{
|
||||
Type: BatchJobReplicateResourceMinIO, Bucket: "remote", Endpoint: "https://source.invalid",
|
||||
Creds: BatchJobReplicateCredentials{AccessKey: "test-access", SecretKey: "test-secret"},
|
||||
},
|
||||
Target: BatchJobReplicateTarget{Type: BatchJobReplicateResourceMinIO, Bucket: fixture.bucket},
|
||||
},
|
||||
}
|
||||
if err := job.Replicate.StartFromSource(fixture.ctx, fixture.obj, job); err != nil {
|
||||
t.Fatalf("explicit remote denial was not terminal: %v", err)
|
||||
}
|
||||
report := fixture.jobInfo(t, &job)
|
||||
if !report.Failed || report.Complete || report.FailureReason == "" || report.PausedReason != "" {
|
||||
t.Fatalf("remote denial saved an invalid outcome: %+v", report)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchReplicationDependencyRecovery(t *testing.T) {
|
||||
for _, mode := range []string{"push", "pull", "snowball"} {
|
||||
for _, denied := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("%s/denied=%t", mode, denied), func(t *testing.T) {
|
||||
t.Setenv("_MINIO_BATCH_REPLICATION_WORKERS", "2")
|
||||
fixture := newBatchAuthzFixture(t)
|
||||
keys := []string{"a-done", "b-pending", "c-later"}
|
||||
versions := make(map[string]string)
|
||||
for _, key := range keys {
|
||||
versions[key] = mustGetUUID()
|
||||
if mode != "pull" {
|
||||
versions[key] = batchPutVersion(t, fixture.obj, fixture.bucket, key, nil).VersionID
|
||||
}
|
||||
}
|
||||
laterDone := make(chan struct{})
|
||||
signalLater := sync.OnceFunc(func() { close(laterDone) })
|
||||
var writtenMu sync.Mutex
|
||||
written := make(map[string]bool)
|
||||
recordWrite := func(bucket, key string) {
|
||||
if bucket != fixture.bucket && bucket != "remote" {
|
||||
return
|
||||
}
|
||||
writtenMu.Lock()
|
||||
written[key] = true
|
||||
writtenMu.Unlock()
|
||||
if key == "c-later" {
|
||||
signalLater()
|
||||
}
|
||||
}
|
||||
var outage atomic.Bool
|
||||
outage.Store(true)
|
||||
var decisions atomic.Int32
|
||||
pluginServer := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
var input struct {
|
||||
Input policy.Args `json:"input"`
|
||||
}
|
||||
if err := json.NewDecoder(request.Body).Decode(&input); err != nil {
|
||||
t.Error(err)
|
||||
writer.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
action := policy.Action(policy.GetObjectAction)
|
||||
if mode == "pull" {
|
||||
action = policy.PutObjectAction
|
||||
}
|
||||
if outage.Load() && input.Input.Action == action && input.Input.ObjectName == "b-pending" {
|
||||
decisions.Add(1)
|
||||
if mode != "snowball" {
|
||||
select {
|
||||
case <-laterDone:
|
||||
case <-request.Context().Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
if denied {
|
||||
io.WriteString(writer, `{"result":false}`)
|
||||
return
|
||||
}
|
||||
writer.WriteHeader(http.StatusServiceUnavailable)
|
||||
}
|
||||
io.WriteString(writer, `{"result":true}`)
|
||||
}))
|
||||
defer pluginServer.Close()
|
||||
pluginURL, err := xnet.ParseHTTPURL(pluginServer.URL)
|
||||
mustIAM(t, err)
|
||||
previousPlugin := newGlobalAuthZPluginFn()
|
||||
setGlobalAuthZPlugin(polplugin.New(polplugin.Args{
|
||||
URL: pluginURL, CloseRespFn: func(reader io.ReadCloser) { reader.Close() },
|
||||
}))
|
||||
defer setGlobalAuthZPlugin(previousPlugin)
|
||||
previousTransport := remoteInstanceTransport
|
||||
remoteInstanceTransport = atomic.Value{}
|
||||
setRemoteInstanceTransport(batchLockTransport(func(request *http.Request) (*http.Response, error) {
|
||||
if err := request.Context().Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body := ""
|
||||
header := make(http.Header)
|
||||
query := request.URL.Query()
|
||||
switch {
|
||||
case query.Has("location"):
|
||||
body = `<LocationConstraint xmlns="http://s3.amazonaws.com/doc/2006-03-01/">us-east-1</LocationConstraint>`
|
||||
case query.Has("versions"):
|
||||
body = `<ListVersionsResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Name>remote</Name><IsTruncated>false</IsTruncated>`
|
||||
for _, key := range keys {
|
||||
body += fmt.Sprintf(`<Version><Key>%s</Key><VersionId>%s</VersionId><IsLatest>true</IsLatest><LastModified>2026-11-01T00:00:00.000Z</LastModified><ETag>"etag"</ETag><Size>7</Size><StorageClass>STANDARD</StorageClass></Version>`, key, versions[key])
|
||||
}
|
||||
body += `</ListVersionsResult>`
|
||||
case request.Method == http.MethodGet:
|
||||
body = "payload"
|
||||
header.Set("Etag", `"etag"`)
|
||||
header.Set("Last-Modified", UTCNow().Format(http.TimeFormat))
|
||||
header.Set("X-Amz-Version-Id", query.Get("versionId"))
|
||||
case request.Method == http.MethodPut:
|
||||
if mode == "snowball" && request.Body != nil {
|
||||
archive := tar.NewReader(request.Body)
|
||||
for {
|
||||
entry, readErr := archive.Next()
|
||||
if readErr == io.EOF {
|
||||
break
|
||||
}
|
||||
if readErr != nil {
|
||||
return nil, readErr
|
||||
}
|
||||
recordWrite("remote", entry.Name)
|
||||
}
|
||||
} else if mode != "snowball" {
|
||||
if _, err := io.Copy(io.Discard, request.Body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
recordWrite("remote", strings.TrimPrefix(request.URL.Path, "/remote/"))
|
||||
}
|
||||
header.Set("Etag", `"etag"`)
|
||||
default:
|
||||
return nil, fmt.Errorf("unexpected replication request: %s %s", request.Method, request.URL)
|
||||
}
|
||||
header.Set("Content-Length", strconv.Itoa(len(body)))
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK, Header: header,
|
||||
Body: io.NopCloser(strings.NewReader(body)), ContentLength: int64(len(body)), Request: request,
|
||||
}, nil
|
||||
}))
|
||||
defer func() { remoteInstanceTransport = previousTransport }()
|
||||
job := &BatchJobRequest{
|
||||
ID: "replicate-retry" + getKeySeparator() + "0", Started: UTCNow(), Submitter: batchRootSubmitter(t),
|
||||
Replicate: &BatchJobReplicateV1{
|
||||
Source: BatchJobReplicateSource{Type: BatchJobReplicateResourceMinIO, Bucket: fixture.bucket},
|
||||
Target: BatchJobReplicateTarget{
|
||||
Type: BatchJobReplicateResourceMinIO, Bucket: "remote", Endpoint: "https://target.invalid",
|
||||
Creds: BatchJobReplicateCredentials{AccessKey: "test-access", SecretKey: "test-secret"},
|
||||
},
|
||||
},
|
||||
}
|
||||
if mode == "pull" {
|
||||
job.Replicate.Source.Endpoint = job.Replicate.Target.Endpoint
|
||||
job.Replicate.Source.Creds = job.Replicate.Target.Creds
|
||||
job.Replicate.Source.Bucket = "remote"
|
||||
job.Replicate.Target.Endpoint = ""
|
||||
job.Replicate.Target.Bucket = fixture.bucket
|
||||
}
|
||||
if mode == "snowball" {
|
||||
job.Replicate.Source.Snowball = BatchJobSnowball{
|
||||
Disable: ptr(false), InMemory: ptr(true), Compress: ptr(false), SkipErrs: ptr(false),
|
||||
Batch: ptr(1), SmallerThan: ptr("1MiB"),
|
||||
}
|
||||
}
|
||||
job.Replicate.Flags.Retry.Attempts = 1
|
||||
job.Replicate.Flags.Retry.Delay = time.Millisecond
|
||||
interrupted := &batchJobInfo{
|
||||
JobID: job.ID, JobType: string(job.Type()), StartTime: job.Started,
|
||||
Object: "c-later", Objects: 2,
|
||||
}
|
||||
mustIAM(t, interrupted.updateAfter(fixture.ctx, fixture.obj, 0, *job))
|
||||
pool := globalBatchJobPool
|
||||
pool.objLayer = batchReplicationWriteObserver{ObjectLayer: fixture.obj, written: recordWrite}
|
||||
ctx, cancel := context.WithCancel(fixture.ctx)
|
||||
pool.ctx = ctx
|
||||
mustIAM(t, pool.queueJob(job))
|
||||
done := make(chan struct{})
|
||||
go func() { defer close(done); pool.AddWorker() }()
|
||||
defer func() {
|
||||
cancel()
|
||||
<-done
|
||||
if t.Failed() {
|
||||
t.Logf("last report: %+v", fixture.jobInfo(t, job))
|
||||
}
|
||||
}()
|
||||
state := "paused"
|
||||
if denied {
|
||||
state = "terminal"
|
||||
}
|
||||
waitBatchState(t, pool, job.ID, state)
|
||||
report := fixture.jobInfo(t, job)
|
||||
if decisions.Load() == 0 {
|
||||
t.Fatal("test did not reach per-object authorization")
|
||||
}
|
||||
if denied {
|
||||
if !report.Failed || report.Complete || report.FailureReason == "" || report.PausedReason != "" {
|
||||
t.Fatalf("denial was not terminal: %+v", report)
|
||||
}
|
||||
pool.retryDue(time.Now().Add(time.Hour))
|
||||
if len(pool.jobCh) != 0 {
|
||||
t.Fatal("denied job was retried")
|
||||
}
|
||||
return
|
||||
}
|
||||
if report.Failed || report.Complete || report.FailureReason != "" || report.PausedReason == "" ||
|
||||
report.Object != "" || report.Objects != 0 || report.ObjectsFailed != 0 {
|
||||
t.Fatalf("outage was not saved with a safe restart point: %+v", report)
|
||||
}
|
||||
pool.jmu.Lock()
|
||||
owner := pool.jobOwners[job.ID]
|
||||
sameOwner := owner.req == job && owner.retries == 1
|
||||
pool.jmu.Unlock()
|
||||
if !sameOwner {
|
||||
t.Fatal("pause lost the job owner")
|
||||
}
|
||||
outage.Store(false)
|
||||
pool.retryDue(time.Now().Add(time.Hour))
|
||||
waitBatchState(t, pool, job.ID, "terminal")
|
||||
report = fixture.jobInfo(t, job)
|
||||
if !report.Complete || report.Failed || report.PausedReason != "" || report.Objects != int64(len(keys)) || report.ObjectsFailed != 0 {
|
||||
t.Fatalf("retry did not finish with consistent counts: %+v", report)
|
||||
}
|
||||
writtenMu.Lock()
|
||||
for _, key := range keys {
|
||||
if !written[key] {
|
||||
t.Errorf("retry skipped %s", key)
|
||||
}
|
||||
}
|
||||
writtenMu.Unlock()
|
||||
if mode == "pull" {
|
||||
for _, key := range keys {
|
||||
if !batchVersionExists(t, fixture.obj, fixture.bucket, key, versions[key]) {
|
||||
t.Errorf("retry lost remote version of %s", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
// Copyright (c) 2026 PGSTY
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/minio/madmin-go/v3"
|
||||
"github.com/minio/minio/internal/auth"
|
||||
)
|
||||
|
||||
// Edits that keep the credential keep a job running; edits of the
|
||||
// credential itself, and its status, still end it.
|
||||
func TestBatchSubmitterCredentialEdits(t *testing.T) {
|
||||
f := newBatchAuthzFixture(t)
|
||||
batchAuthzPolicy(f.ctx, t, f.sys, "edit-parent", batchAuthzFullAccess)
|
||||
parent := iamAdminUser(f.ctx, t, f.sys, "edit-parent", "edit-parent")
|
||||
soon := UTCNow().Add(time.Hour)
|
||||
later := UTCNow().Add(24 * time.Hour)
|
||||
newService := func(t *testing.T, expiring bool) (auth.Credentials, *batchJobSubmitter) {
|
||||
t.Helper()
|
||||
var opts newServiceAccountOpts
|
||||
if expiring {
|
||||
opts.expiration = &soon
|
||||
}
|
||||
created, _, err := f.sys.NewServiceAccount(f.ctx, parent.AccessKey, nil, opts)
|
||||
mustIAM(t, err)
|
||||
stored, ok := f.sys.GetUser(f.ctx, created.AccessKey)
|
||||
if !ok {
|
||||
t.Fatal("service account missing")
|
||||
}
|
||||
sub, err := newBatchJobSubmitter(stored.Credentials)
|
||||
mustIAM(t, err)
|
||||
mustIAM(t, sub.authorize(f.ctx, batchContextRead(f)))
|
||||
return stored.Credentials, sub
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
expiring bool
|
||||
edit func(t *testing.T, cred auth.Credentials)
|
||||
keep bool
|
||||
}{
|
||||
{"description", false, func(t *testing.T, cred auth.Credentials) {
|
||||
_, err := f.sys.UpdateServiceAccount(f.ctx, cred.AccessKey, updateServiceAccountOpts{description: "cosmetic"})
|
||||
mustIAM(t, err)
|
||||
}, true},
|
||||
{"name", false, func(t *testing.T, cred auth.Credentials) {
|
||||
_, err := f.sys.UpdateServiceAccount(f.ctx, cred.AccessKey, updateServiceAccountOpts{name: "renamed"})
|
||||
mustIAM(t, err)
|
||||
}, true},
|
||||
{"expiry-extended", true, func(t *testing.T, cred auth.Credentials) {
|
||||
_, err := f.sys.UpdateServiceAccount(f.ctx, cred.AccessKey, updateServiceAccountOpts{expiration: &later})
|
||||
mustIAM(t, err)
|
||||
}, true},
|
||||
// An expiry makes a service account a temporary credential, which
|
||||
// IsAllowed evaluates differently.
|
||||
{"expiry-added", false, func(t *testing.T, cred auth.Credentials) {
|
||||
_, err := f.sys.UpdateServiceAccount(f.ctx, cred.AccessKey, updateServiceAccountOpts{expiration: &later})
|
||||
mustIAM(t, err)
|
||||
}, false},
|
||||
{"groups-refreshed", false, func(t *testing.T, cred auth.Credentials) {
|
||||
// As the LDAP group refresh rewrites a derived credential.
|
||||
u, ok := f.sys.GetUser(f.ctx, cred.AccessKey)
|
||||
if !ok {
|
||||
t.Fatal("service account missing")
|
||||
}
|
||||
wrote, err := f.sys.store.RefreshUserGroups(f.ctx, u.Credentials, append(u.Credentials.Groups, "refreshed-group"))
|
||||
mustIAM(t, err)
|
||||
if !wrote {
|
||||
t.Fatal("control: the refresh did not write")
|
||||
}
|
||||
}, true},
|
||||
{"secret-rotated", false, func(t *testing.T, cred auth.Credentials) {
|
||||
_, err := f.sys.UpdateServiceAccount(f.ctx, cred.AccessKey, updateServiceAccountOpts{secretKey: "edit-rotated-secret-key"})
|
||||
mustIAM(t, err)
|
||||
}, false},
|
||||
{"disabled", false, func(t *testing.T, cred auth.Credentials) {
|
||||
_, err := f.sys.UpdateServiceAccount(f.ctx, cred.AccessKey, updateServiceAccountOpts{status: string(madmin.AccountDisabled)})
|
||||
mustIAM(t, err)
|
||||
}, false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cred, sub := newService(t, tc.expiring)
|
||||
before, _ := f.sys.GetUser(f.ctx, cred.AccessKey)
|
||||
time.Sleep(2 * time.Millisecond) // a distinct UpdatedAt
|
||||
tc.edit(t, cred)
|
||||
after, _ := f.sys.GetUser(f.ctx, cred.AccessKey)
|
||||
if after.UpdatedAt.Equal(before.UpdatedAt) {
|
||||
t.Fatal("control: the edit did not change the identity record")
|
||||
}
|
||||
err := sub.authorize(f.ctx, batchContextRead(f))
|
||||
if tc.keep && err != nil {
|
||||
t.Errorf("an edit that keeps the credential ended the job: %v", err)
|
||||
}
|
||||
if !tc.keep && !isBatchJobAuthzError(err) {
|
||||
t.Errorf("an edit of the credential must end the job, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A user deleted and created again under its access key, with the same
|
||||
// secret, is not the credential that submitted the job.
|
||||
func TestBatchSubmitterRecreatedSameSecret(t *testing.T) {
|
||||
f := newBatchAuthzFixture(t)
|
||||
batchAuthzPolicy(f.ctx, t, f.sys, "recreate-full", batchAuthzFullAccess)
|
||||
cred := iamAdminUser(f.ctx, t, f.sys, "recreate-user", "recreate-full")
|
||||
sub, err := newBatchJobSubmitter(cred)
|
||||
mustIAM(t, err)
|
||||
mustIAM(t, sub.authorize(f.ctx, batchContextRead(f)))
|
||||
mustIAM(t, f.sys.DeleteUser(f.ctx, cred.AccessKey, false))
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
again := iamAdminUser(f.ctx, t, f.sys, cred.AccessKey, "recreate-full")
|
||||
if again.SecretKey != cred.SecretKey {
|
||||
t.Fatal("control: the user must be created again with the same secret")
|
||||
}
|
||||
err = sub.authorize(f.ctx, batchContextRead(f))
|
||||
t.Logf("recreated: %v", err)
|
||||
if !isBatchJobAuthzError(err) {
|
||||
t.Errorf("a recreated user must not run the job of the deleted one: %v", err)
|
||||
}
|
||||
}
|
||||
+93
-50
@@ -478,15 +478,11 @@ func (api objectAPIHandlers) DeleteMultipleObjectsHandler(w http.ResponseWriter,
|
||||
getObjectInfoFn := objectAPI.GetObjectInfo
|
||||
|
||||
var (
|
||||
hasLockEnabled bool
|
||||
dsc ReplicateDecision
|
||||
goi ObjectInfo
|
||||
gerr error
|
||||
)
|
||||
replicateDeletes := hasReplicationRules(ctx, bucket, deleteObjectsReq.Objects)
|
||||
if rcfg, _ := globalBucketObjectLockSys.Get(bucket); rcfg.LockEnabled {
|
||||
hasLockEnabled = true
|
||||
}
|
||||
|
||||
type deleteResult struct {
|
||||
delInfo DeletedObject
|
||||
@@ -494,6 +490,7 @@ func (api objectAPIHandlers) DeleteMultipleObjectsHandler(w http.ResponseWriter,
|
||||
}
|
||||
|
||||
deleteResults := make([]deleteResult, len(deleteObjectsReq.Objects))
|
||||
duplicateOf := make(map[int]int)
|
||||
|
||||
vc, _ := globalBucketVersioningSys.Get(bucket)
|
||||
oss := make([]*objSweeper, len(deleteObjectsReq.Objects))
|
||||
@@ -523,13 +520,32 @@ func (api objectAPIHandlers) DeleteMultipleObjectsHandler(w http.ResponseWriter,
|
||||
}
|
||||
}
|
||||
|
||||
// All deletes on directory objects needs to be for `nullVersionID`.
|
||||
// Decide this before the lookup below, so that the retention check
|
||||
// reads the version the delete removes.
|
||||
if isDirObject(object.ObjectName) && object.VersionID == "" {
|
||||
object.VersionID = nullVersionID
|
||||
}
|
||||
|
||||
opts := ObjectOptions{
|
||||
VersionID: object.VersionID,
|
||||
Versioned: vc.PrefixEnabled(object.ObjectName),
|
||||
VersionSuspended: vc.Suspended(),
|
||||
}
|
||||
|
||||
if replicateDeletes || object.VersionID != "" && hasLockEnabled || !globalTierConfigMgr.Empty() {
|
||||
// Every physical version delete checks the version's own protection
|
||||
// under its mutation lock. Bucket metadata may be stale or unavailable
|
||||
// and cannot establish that a stored version is unprotected.
|
||||
if object.VersionID != "" {
|
||||
if first, ok := objectsToDelete[object]; ok {
|
||||
duplicateOf[index] = first
|
||||
} else {
|
||||
objectsToDelete[object] = index
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if replicateDeletes || !globalTierConfigMgr.Empty() {
|
||||
if !globalTierConfigMgr.Empty() && object.VersionID == "" && opts.VersionSuspended {
|
||||
opts.VersionID = nullVersionID
|
||||
}
|
||||
@@ -541,11 +557,6 @@ func (api objectAPIHandlers) DeleteMultipleObjectsHandler(w http.ResponseWriter,
|
||||
oss[index].SetTransitionState(goi.TransitionedObject)
|
||||
}
|
||||
|
||||
// All deletes on directory objects needs to be for `nullVersionID`
|
||||
if isDirObject(object.ObjectName) && object.VersionID == "" {
|
||||
object.VersionID = nullVersionID
|
||||
}
|
||||
|
||||
if replicateDeletes {
|
||||
dsc = checkReplicateDelete(ctx, bucket, ObjectToDelete{
|
||||
ObjectV: ObjectV{
|
||||
@@ -563,21 +574,10 @@ func (api objectAPIHandlers) DeleteMultipleObjectsHandler(w http.ResponseWriter,
|
||||
object.ReplicateDecisionStr = dsc.String()
|
||||
}
|
||||
}
|
||||
if object.VersionID != "" && hasLockEnabled {
|
||||
if err := enforceRetentionBypassForDelete(ctx, r, bucket, object, goi, gerr); err != nil {
|
||||
apiErr := toAPIError(ctx, err)
|
||||
deleteResults[index].errInfo = DeleteError{
|
||||
Code: apiErr.Code,
|
||||
Message: apiErr.Description,
|
||||
Key: object.ObjectName,
|
||||
VersionID: object.VersionID,
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Avoid duplicate objects, we use map to filter them out.
|
||||
if _, ok := objectsToDelete[object]; !ok {
|
||||
if first, ok := objectsToDelete[object]; ok {
|
||||
duplicateOf[index] = first
|
||||
} else {
|
||||
objectsToDelete[object] = index
|
||||
}
|
||||
}
|
||||
@@ -596,10 +596,57 @@ func (api objectAPIHandlers) DeleteMultipleObjectsHandler(w http.ResponseWriter,
|
||||
ctx = bgContext(ctx)
|
||||
|
||||
deleteList := toNames(objectsToDelete)
|
||||
dObjects, errs := deleteObjectsFn(ctx, bucket, deleteList, ObjectOptions{
|
||||
dObjects := make([]DeletedObject, len(deleteList))
|
||||
errs := make([]error, len(deleteList))
|
||||
bulk := make([]ObjectToDelete, 0, len(deleteList))
|
||||
bulkIndex := make([]int, 0, len(deleteList))
|
||||
for i, object := range deleteList {
|
||||
if object.VersionID == "" {
|
||||
bulk = append(bulk, object)
|
||||
bulkIndex = append(bulkIndex, i)
|
||||
continue
|
||||
}
|
||||
reqInfo.ObjectName, reqInfo.VersionID = object.ObjectName, object.VersionID
|
||||
opts := ObjectOptions{
|
||||
VersionID: object.VersionID,
|
||||
Versioned: vc.PrefixEnabled(object.ObjectName),
|
||||
VersionSuspended: vc.Suspended(),
|
||||
}
|
||||
index := objectsToDelete[object]
|
||||
if !globalTierConfigMgr.Empty() {
|
||||
oss[index] = newObjSweeper(bucket, object.ObjectName).WithVersion(object.VersionID).WithVersioning(opts.Versioned, opts.VersionSuspended)
|
||||
}
|
||||
opts.SetEvalRetentionBypassFn(func(oi ObjectInfo, err error) error {
|
||||
return enforceRetentionBypassForDelete(ctx, r, bucket, object, oi, err)
|
||||
})
|
||||
opts.SetEvalMetadataFn(func(oi *ObjectInfo, err error) (ReplicateDecision, error) {
|
||||
if err == nil && oss[index] != nil {
|
||||
oss[index].SetTransitionState(oi.TransitionedObject)
|
||||
}
|
||||
if replicateDeletes {
|
||||
return checkReplicateDelete(ctx, bucket, object, *oi, opts, err), nil
|
||||
}
|
||||
return ReplicateDecision{}, nil
|
||||
})
|
||||
// DeleteObject merges protection across pools before evaluating these
|
||||
// callbacks and holds the same lock as retention and legal-hold writes.
|
||||
oi, err := objectAPI.DeleteObject(ctx, bucket, object.ObjectName, opts)
|
||||
dObjects[i] = DeletedObject{
|
||||
ObjectName: object.ObjectName,
|
||||
VersionID: object.VersionID,
|
||||
ReplicationState: oi.ReplicationState(),
|
||||
}
|
||||
errs[i] = err
|
||||
}
|
||||
if len(bulk) > 0 {
|
||||
deleted, bulkErrs := deleteObjectsFn(ctx, bucket, bulk, ObjectOptions{
|
||||
PrefixEnabledFn: vc.PrefixEnabled,
|
||||
VersionSuspended: vc.Suspended(),
|
||||
})
|
||||
for i, index := range bulkIndex {
|
||||
dObjects[index], errs[index] = deleted[i], bulkErrs[i]
|
||||
}
|
||||
}
|
||||
|
||||
// Are all objects saying bucket not found?
|
||||
if isAllBucketsNotFound(errs) {
|
||||
@@ -608,23 +655,11 @@ func (api objectAPIHandlers) DeleteMultipleObjectsHandler(w http.ResponseWriter,
|
||||
}
|
||||
|
||||
for i := range errs {
|
||||
// DeleteMarkerVersionID is not used specifically to avoid
|
||||
// lookup errors, since DeleteMarkerVersionID is only
|
||||
// created during DeleteMarker creation when client didn't
|
||||
// specify a versionID.
|
||||
objToDel := ObjectToDelete{
|
||||
ObjectV: ObjectV{
|
||||
ObjectName: decodeDirObject(dObjects[i].ObjectName),
|
||||
VersionID: dObjects[i].VersionID,
|
||||
},
|
||||
VersionPurgeStatus: dObjects[i].VersionPurgeStatus(),
|
||||
VersionPurgeStatuses: dObjects[i].ReplicationState.VersionPurgeStatusInternal,
|
||||
DeleteMarkerReplicationStatus: dObjects[i].ReplicationState.ReplicationStatusInternal,
|
||||
ReplicateDecisionStr: dObjects[i].ReplicationState.ReplicateDecisionStr,
|
||||
}
|
||||
dindex := objectsToDelete[objToDel]
|
||||
// Match by the input identity: replication decisions may have changed
|
||||
// under the mutation lock, and a missing version has no stored identity.
|
||||
dindex := objectsToDelete[deleteList[i]]
|
||||
if errs[i] == nil || isErrObjectNotFound(errs[i]) || isErrVersionNotFound(errs[i]) {
|
||||
if replicateDeletes {
|
||||
if replicateDeletes && deleteList[i].VersionID == "" {
|
||||
dObjects[i].ReplicationState = deleteList[i].ReplicationState()
|
||||
}
|
||||
deleteResults[dindex].delInfo = dObjects[i]
|
||||
@@ -638,20 +673,28 @@ func (api objectAPIHandlers) DeleteMultipleObjectsHandler(w http.ResponseWriter,
|
||||
VersionID: deleteList[i].VersionID,
|
||||
}
|
||||
}
|
||||
for index, first := range duplicateOf {
|
||||
deleteResults[index] = deleteResults[first]
|
||||
}
|
||||
|
||||
// Generate response
|
||||
deleteErrors := make([]DeleteError, 0, len(deleteObjectsReq.Objects))
|
||||
deletedObjects := make([]DeletedObject, 0, len(deleteObjectsReq.Objects))
|
||||
for _, deleteResult := range deleteResults {
|
||||
eventObjects := make([]DeletedObject, 0, len(deleteObjectsReq.Objects))
|
||||
for index, deleteResult := range deleteResults {
|
||||
// Storage normalizes a directory to the null version. The response
|
||||
// retains the caller's version identity, including an explicit null.
|
||||
if isDirObject(deleteObjectsReq.Objects[index].ObjectName) {
|
||||
deleteResult.delInfo.VersionID = deleteObjectsReq.Objects[index].VersionID
|
||||
deleteResult.errInfo.VersionID = deleteObjectsReq.Objects[index].VersionID
|
||||
}
|
||||
if deleteResult.errInfo.Code != "" {
|
||||
deleteErrors = append(deleteErrors, deleteResult.errInfo)
|
||||
} else {
|
||||
// All deletes on directory objects was with `nullVersionID`.
|
||||
// Remove it from response.
|
||||
if isDirObject(deleteResult.delInfo.ObjectName) && deleteResult.delInfo.VersionID == nullVersionID {
|
||||
deleteResult.delInfo.VersionID = ""
|
||||
}
|
||||
deletedObjects = append(deletedObjects, deleteResult.delInfo)
|
||||
if _, duplicate := duplicateOf[index]; !duplicate {
|
||||
eventObjects = append(eventObjects, deleteResult.delInfo)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -660,7 +703,7 @@ func (api objectAPIHandlers) DeleteMultipleObjectsHandler(w http.ResponseWriter,
|
||||
|
||||
// Write success response.
|
||||
writeSuccessResponseXML(w, encodedSuccessResponse)
|
||||
for _, dobj := range deletedObjects {
|
||||
for _, dobj := range eventObjects {
|
||||
if dobj.ObjectName == "" {
|
||||
continue
|
||||
}
|
||||
@@ -703,8 +746,8 @@ func (api objectAPIHandlers) DeleteMultipleObjectsHandler(w http.ResponseWriter,
|
||||
}
|
||||
|
||||
// Clean up transitioned objects from remote tier
|
||||
for _, os := range oss {
|
||||
if os == nil { // skip objects that weren't deleted due to invalid versionID etc.
|
||||
for index, os := range oss {
|
||||
if os == nil || deleteResults[index].errInfo.Code != "" { // Sweep only successful deletions.
|
||||
continue
|
||||
}
|
||||
os.Sweep()
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
// Copyright (c) 2026 Feng Ruohang
|
||||
//
|
||||
// This file is part of Silo Object Storage stack
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/minio/minio/internal/auth"
|
||||
)
|
||||
|
||||
type metadataCacheLoadBarrier struct {
|
||||
ObjectLayer
|
||||
key string
|
||||
read, resume chan struct{}
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func (b *metadataCacheLoadBarrier) GetObjectNInfo(ctx context.Context, bucket, key string, rs *HTTPRangeSpec, h http.Header, opts ObjectOptions) (*GetObjectReader, error) {
|
||||
r, err := b.ObjectLayer.GetObjectNInfo(ctx, bucket, key, rs, h, opts)
|
||||
if bucket == minioMetaBucket && key == b.key {
|
||||
b.once.Do(func() { close(b.read); <-b.resume })
|
||||
}
|
||||
return r, err
|
||||
}
|
||||
|
||||
func TestBucketMetadataLoadPreservesConcurrentPublication(t *testing.T) {
|
||||
defer DetectTestLeak(t)()
|
||||
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
|
||||
t: t,
|
||||
objAPITest: func(obj ObjectLayer, backend, bucket string, _ http.Handler, _ auth.Credentials, t *testing.T) {
|
||||
// Disk has the old unlocked config; a concurrent publication enables
|
||||
// lock and versioning after the cache miss has started loading it.
|
||||
enableBucketObjectLock(t, bucket)
|
||||
newer, err := globalBucketMetadataSys.Get(bucket)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
globalBucketMetadataSys.Remove(bucket)
|
||||
barrier := &metadataCacheLoadBarrier{ObjectLayer: obj, key: pathJoin(bucketMetaPrefix, bucket, bucketMetadataFile), read: make(chan struct{}), resume: make(chan struct{})}
|
||||
previous := newObjectLayerFn()
|
||||
setObjectLayer(barrier)
|
||||
defer setObjectLayer(previous)
|
||||
release := sync.OnceFunc(func() { close(barrier.resume) })
|
||||
defer release()
|
||||
type result struct {
|
||||
meta BucketMetadata
|
||||
reloaded bool
|
||||
err error
|
||||
}
|
||||
done := make(chan result, 1)
|
||||
go func() {
|
||||
meta, reloaded, err := globalBucketMetadataSys.GetConfig(t.Context(), bucket)
|
||||
done <- result{meta, reloaded, err}
|
||||
}()
|
||||
select {
|
||||
case <-barrier.read:
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("metadata load did not reach disk")
|
||||
}
|
||||
globalBucketMetadataSys.Set(bucket, newer)
|
||||
release()
|
||||
select {
|
||||
case got := <-done:
|
||||
if got.err != nil || got.reloaded || got.meta.objectLockConfig == nil || got.meta.objectLockConfig.ObjectLockEnabled != "Enabled" {
|
||||
t.Fatalf("%s: stale load replaced the publication: %+v", backend, got)
|
||||
}
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("metadata load did not finish")
|
||||
}
|
||||
cached, err := globalBucketMetadataSys.Get(bucket)
|
||||
if err != nil || cached.objectLockConfig == nil || cached.objectLockConfig.ObjectLockEnabled != "Enabled" {
|
||||
t.Fatalf("cached lock lost: %+v %v", cached, err)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestBulkDeleteVersionLockWithStaleBucketCache(t *testing.T) {
|
||||
for _, pools := range []int{1, 2} {
|
||||
z, _ := bulkDeletePools(t, pools)
|
||||
bucket, router, err := initAPIHandlerTest(t.Context(), z, nil, MakeBucketOptions{LockEnabled: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, protection := range batchLockKinds {
|
||||
oi := batchPutVersion(t, z, bucket, protection, batchLockMeta(protection))
|
||||
saved, err := globalBucketMetadataSys.Get(bucket)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
globalBucketMetadataSys.Set(bucket, newBucketMetadata(bucket))
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, bulkDeleteLockRequest(t, bucket, protection, oi.VersionID))
|
||||
globalBucketMetadataSys.Set(bucket, saved)
|
||||
var response DeleteObjectsResponse
|
||||
if err := xml.Unmarshal(rec.Body.Bytes(), &response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec.Code != http.StatusOK || len(response.Errors) != 1 || len(response.DeletedObjects) != 0 || !batchVersionExists(t, z, bucket, protection, oi.VersionID) {
|
||||
t.Fatalf("pools=%d %s: stale bucket cache bypassed version lock: %s", pools, protection, rec.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -697,7 +697,7 @@ func (sys *BucketMetadataSys) GetConfig(ctx context.Context, bucket string) (met
|
||||
}
|
||||
|
||||
val, err, _ := sys.group.Do(bucket, func() (val any, err error) {
|
||||
meta, err = loadBucketMetadata(ctx, objAPI, bucket)
|
||||
meta, err := loadBucketMetadata(ctx, objAPI, bucket)
|
||||
if err != nil {
|
||||
if !sys.Initialized() {
|
||||
// bucket metadata not yet initialized
|
||||
@@ -707,13 +707,18 @@ func (sys *BucketMetadataSys) GetConfig(ctx context.Context, bucket string) (met
|
||||
return meta, err
|
||||
})
|
||||
meta, _ = val.(BucketMetadata)
|
||||
sys.Lock()
|
||||
defer sys.Unlock()
|
||||
// A writer may have published a newer configuration while the disk read
|
||||
// was in flight. Never replace that publication with the older load.
|
||||
if current, ok := sys.metadataMap[bucket]; ok {
|
||||
return current, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return meta, false, err
|
||||
}
|
||||
sys.Lock()
|
||||
sys.metadataMap[bucket] = meta
|
||||
sys.clearLoadFailure(bucket)
|
||||
sys.Unlock()
|
||||
|
||||
return meta, true, nil
|
||||
}
|
||||
|
||||
+129
-5
@@ -354,6 +354,8 @@ func checkPutObjectLockAllowed(ctx context.Context, rq *http.Request, bucket, ob
|
||||
var legalHold objectlock.ObjectLegalHold
|
||||
|
||||
retentionRequested := objectlock.IsObjectLockRetentionRequested(rq.Header)
|
||||
// A timestamp without a value is a replicated retention removal.
|
||||
retentionRequested = retentionRequested || replicaTrusted && rq.Header.Get(xhttp.MinIOSourceObjectRetentionTimestamp) != ""
|
||||
legalHoldRequested := objectlock.IsObjectLockLegalHoldRequested(rq.Header)
|
||||
|
||||
retentionCfg, err := globalBucketObjectLockSys.Get(bucket)
|
||||
@@ -472,6 +474,103 @@ func storedObjectLockState(metadata map[string]string) objectLockState {
|
||||
}
|
||||
}
|
||||
|
||||
// reducePoolLockState selects each field independently using a total order,
|
||||
// so neither pool enumeration nor the starting copy can change the result.
|
||||
// Active COMPLIANCE takes precedence over revisions; among those copies the
|
||||
// longest retention wins. Only expired COMPLIANCE may lose to a removal.
|
||||
func reducePoolLockState(copies []PoolObjInfo, readState func(map[string]string) objectLockState) objectLockState {
|
||||
var now time.Time
|
||||
clockRead := false
|
||||
state := readState(copies[0].ObjInfo.UserDefined)
|
||||
for _, copy := range copies[1:] {
|
||||
next := readState(copy.ObjInfo.UserDefined)
|
||||
if !clockRead && (state.mode == string(objectlock.RetCompliance) || next.mode == string(objectlock.RetCompliance)) {
|
||||
var err error
|
||||
now, err = objectlock.UTCNowNTP()
|
||||
if err != nil {
|
||||
now = time.Time{} // An unavailable clock cannot prove retention expired.
|
||||
}
|
||||
clockRead = true
|
||||
}
|
||||
if comparePoolRetention(next, state, now) > 0 {
|
||||
state.mode, state.retainUntil, state.retentionTimestamp = next.mode, next.retainUntil, next.retentionTimestamp
|
||||
}
|
||||
order := compareLockRevision(next.legalHoldTimestamp, state.legalHoldTimestamp)
|
||||
// Equal or unordered conflicting holds resolve to ON, then OFF, then
|
||||
// absence. A strictly newer valid OFF still releases a prior hold.
|
||||
if order == 0 {
|
||||
order = strings.Compare(next.legalHold, state.legalHold)
|
||||
}
|
||||
if order == 0 {
|
||||
order = strings.Compare(next.legalHoldTimestamp, state.legalHoldTimestamp)
|
||||
}
|
||||
if order > 0 {
|
||||
state.legalHold, state.legalHoldTimestamp = next.legalHold, next.legalHoldTimestamp
|
||||
}
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
func compareLockRevision(a, b string) int {
|
||||
ta, aok := lockRevisionTime(a)
|
||||
tb, bok := lockRevisionTime(b)
|
||||
if aok != bok {
|
||||
if aok {
|
||||
return 1
|
||||
}
|
||||
return -1
|
||||
}
|
||||
if !aok {
|
||||
return 0
|
||||
}
|
||||
return ta.Compare(tb)
|
||||
}
|
||||
|
||||
func comparePoolRetention(a, b objectLockState, now time.Time) int {
|
||||
au, ae := time.Parse(time.RFC3339Nano, a.retainUntil)
|
||||
bu, be := time.Parse(time.RFC3339Nano, b.retainUntil)
|
||||
ap := a.mode == string(objectlock.RetCompliance) && (ae != nil || now.IsZero() || au.After(now))
|
||||
bp := b.mode == string(objectlock.RetCompliance) && (be != nil || now.IsZero() || bu.After(now))
|
||||
if ap != bp {
|
||||
if ap {
|
||||
return 1
|
||||
}
|
||||
return -1
|
||||
}
|
||||
if ap { // An unreadable COMPLIANCE deadline cannot be shortened either.
|
||||
if (ae != nil) != (be != nil) {
|
||||
if ae != nil {
|
||||
return 1
|
||||
}
|
||||
return -1
|
||||
}
|
||||
if ae == nil && au.Compare(bu) != 0 {
|
||||
return au.Compare(bu)
|
||||
}
|
||||
}
|
||||
if order := compareLockRevision(a.retentionTimestamp, b.retentionTimestamp); order != 0 {
|
||||
return order
|
||||
}
|
||||
// Ties and legacy unordered metadata must also have a stable winner.
|
||||
// A retained value takes precedence over an unordered absence.
|
||||
if order := strings.Compare(a.mode, b.mode); order != 0 {
|
||||
return order
|
||||
}
|
||||
if (ae != nil) != (be != nil) {
|
||||
if ae != nil {
|
||||
return 1
|
||||
}
|
||||
return -1
|
||||
}
|
||||
if ae == nil && be == nil && au.Compare(bu) != 0 {
|
||||
return au.Compare(bu)
|
||||
}
|
||||
if order := strings.Compare(a.retainUntil, b.retainUntil); order != 0 {
|
||||
return order
|
||||
}
|
||||
return strings.Compare(a.retentionTimestamp, b.retentionTimestamp)
|
||||
}
|
||||
|
||||
// olderThan reports whether a stored replication timestamp is missing,
|
||||
// unreadable, or earlier than the source timestamp, in which case the
|
||||
// replica update wins. A zero source timestamp never wins.
|
||||
@@ -491,6 +590,25 @@ func (s objectLockState) legalHoldIsOlderThan(src time.Time) bool {
|
||||
return olderThan(s.legalHoldTimestamp, src)
|
||||
}
|
||||
|
||||
// A peer's ordering timestamp cannot override an unexpired COMPLIANCE lock.
|
||||
// Otherwise it could first remove retention with a same-content retransmit,
|
||||
// then replace the now-unprotected version in a second request.
|
||||
func (s objectLockState) complianceWouldWeaken(incoming objectLockState) bool {
|
||||
if s.mode != string(objectlock.RetCompliance) {
|
||||
return false
|
||||
}
|
||||
until, err := time.Parse(time.RFC3339Nano, s.retainUntil)
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
now, err := objectlock.UTCNowNTP()
|
||||
if err == nil && !until.After(now) {
|
||||
return false
|
||||
}
|
||||
nextUntil, nextErr := time.Parse(time.RFC3339Nano, incoming.retainUntil)
|
||||
return incoming.mode != string(objectlock.RetCompliance) || nextErr != nil || nextUntil.Before(until)
|
||||
}
|
||||
|
||||
// restoreRetention and restoreLegalHold put the stored state back into
|
||||
// metadata that was rebuilt from a request whose update was not applied.
|
||||
func (s objectLockState) restoreRetention(metadata map[string]string) {
|
||||
@@ -524,14 +642,20 @@ func (s objectLockState) restoreLegalHold(metadata map[string]string) {
|
||||
// (an older incoming value must not win over a newer stored one just because the
|
||||
// read timed out).
|
||||
func replicaStoredLock(ctx context.Context, getObjectInfo GetObjectInfoFn, bucket, object, versionID string) (objectLockState, error) {
|
||||
oi, err := getObjectInfo(ctx, bucket, object, ObjectOptions{VersionID: versionID})
|
||||
oi, err := replicaStoredObjectInfo(ctx, getObjectInfo, bucket, object, versionID)
|
||||
return storedObjectLockState(oi.UserDefined), err
|
||||
}
|
||||
|
||||
// The same destination lookup supplies both Object Lock and tag revisions.
|
||||
func replicaStoredObjectInfo(ctx context.Context, getObjectInfo GetObjectInfoFn, bucket, object, versionID string) (ObjectInfo, error) {
|
||||
oi, err := getObjectInfo(ctx, bucket, object, ObjectOptions{VersionID: versionID, ReplicationRequest: true})
|
||||
switch {
|
||||
case err == nil:
|
||||
return storedObjectLockState(oi.UserDefined), nil
|
||||
return oi, nil
|
||||
case isErrObjectNotFound(err) || isErrVersionNotFound(err):
|
||||
return objectLockState{}, nil
|
||||
return ObjectInfo{}, nil
|
||||
default:
|
||||
return objectLockState{}, err
|
||||
return ObjectInfo{}, err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -610,7 +734,7 @@ func reconcileStoredObjectLock(metadata map[string]string, stored objectLockStat
|
||||
incoming := storedObjectLockState(metadata)
|
||||
|
||||
incomingRetentionTS, _ := time.Parse(time.RFC3339Nano, incoming.retentionTimestamp)
|
||||
if !stored.retentionIsOlderThan(incomingRetentionTS) {
|
||||
if !stored.retentionIsOlderThan(incomingRetentionTS) || stored.complianceWouldWeaken(incoming) {
|
||||
// The stored retention is at least as new as the incoming one (or the
|
||||
// incoming update is unordered): drop the incoming value and put the stored
|
||||
// state back, which may itself be a removal (value keys absent, timestamp
|
||||
|
||||
+13
-2
@@ -213,16 +213,27 @@ func getConditionValuesWithTags(r *http.Request, lc string, cred auth.Credential
|
||||
switch authType {
|
||||
case authTypeSignedV2, authTypePresignedV2:
|
||||
signatureVersion = signV2Algorithm
|
||||
case authTypeSigned, authTypePresigned, authTypeStreamingSigned, authTypePostPolicy:
|
||||
case authTypeSigned, authTypePresigned, authTypeStreamingSigned,
|
||||
authTypeStreamingSignedTrailer, authTypePostPolicy:
|
||||
signatureVersion = signV4Algorithm
|
||||
case authTypeStreamingUnsignedTrailer:
|
||||
// This form is also valid for a genuinely anonymous request. Only
|
||||
// expose SigV4 condition values when header authentication is present.
|
||||
if r.Header.Get(xhttp.Authorization) != "" {
|
||||
signatureVersion = signV4Algorithm
|
||||
}
|
||||
}
|
||||
|
||||
var authtype string
|
||||
switch authType {
|
||||
case authTypePresignedV2, authTypePresigned:
|
||||
authtype = "REST-QUERY-STRING"
|
||||
case authTypeSignedV2, authTypeSigned, authTypeStreamingSigned:
|
||||
case authTypeSignedV2, authTypeSigned, authTypeStreamingSigned, authTypeStreamingSignedTrailer:
|
||||
authtype = "REST-HEADER"
|
||||
case authTypeStreamingUnsignedTrailer:
|
||||
if r.Header.Get(xhttp.Authorization) != "" {
|
||||
authtype = "REST-HEADER"
|
||||
}
|
||||
case authTypePostPolicy:
|
||||
authtype = "POST"
|
||||
}
|
||||
|
||||
@@ -119,7 +119,10 @@ func (a *ActiveWorkerStat) update() {
|
||||
if a == nil {
|
||||
return
|
||||
}
|
||||
a.Curr = globalReplicationPool.Get().ActiveWorkers()
|
||||
a.Curr = 0
|
||||
if pool := globalReplicationPool.Get(); pool != nil {
|
||||
a.Curr = pool.ActiveWorkers()
|
||||
}
|
||||
a.hist.Update(int64(a.Curr))
|
||||
a.Avg = float32(a.hist.Mean())
|
||||
a.Max = int(a.hist.Max())
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
// Copyright (c) 2026 PGSTY
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/minio/minio/internal/once"
|
||||
"github.com/rcrowley/go-metrics"
|
||||
)
|
||||
|
||||
func TestReplicationWorkerMetricsWithoutPool(t *testing.T) {
|
||||
previous := globalReplicationPool
|
||||
t.Cleanup(func() { globalReplicationPool = previous })
|
||||
|
||||
globalReplicationPool = once.NewSingleton[ReplicationPool]()
|
||||
globalReplicationPool.Set(&ReplicationPool{activeWorkers: 8})
|
||||
stat := newActiveWorkerStat(metrics.NewRegistry())
|
||||
stat.update()
|
||||
if got := stat.get(); got.Curr != 8 || got.Avg != 8 || got.Max != 8 {
|
||||
t.Fatalf("active pool metrics = %+v, want current/average/maximum 8", got)
|
||||
}
|
||||
|
||||
// A collector may still run after replication has been disabled.
|
||||
globalReplicationPool = once.NewSingleton[ReplicationPool]()
|
||||
globalReplicationPool.Set(nil)
|
||||
stat.update()
|
||||
if got := stat.get(); got.Curr != 0 || got.Avg != 4 || got.Max != 8 {
|
||||
t.Fatalf("disabled pool metrics = %+v, want current 0, average 4, maximum 8", got)
|
||||
}
|
||||
}
|
||||
+112
-104
@@ -2773,127 +2773,135 @@ func scheduleReplication(ctx context.Context, oi ObjectInfo, o ObjectLayer, dsc
|
||||
}
|
||||
}
|
||||
|
||||
// proxyTaggingToRepTarget proxies tagging requests to remote targets for
|
||||
// active-active replicated setups
|
||||
func proxyTaggingToRepTarget(ctx context.Context, bucket, object string, tags *tags.Tags, opts ObjectOptions, proxyTargets *madmin.BucketTargets) (proxy proxyResult) {
|
||||
// this option is set when active-active replication is in place between site A -> B,
|
||||
// and request hits site B that does not have the object yet.
|
||||
if opts.ProxyRequest || (opts.ProxyHeaderSet && !opts.ProxyRequest) { // true only when site B sets MinIOSourceProxyRequest header
|
||||
return proxy
|
||||
// taggingProxyTarget keeps the exact client and tags that were authorized.
|
||||
// A second discovery must not introduce an unauthorized destination.
|
||||
type taggingProxyTarget struct {
|
||||
client *TargetClient
|
||||
tags *tags.Tags
|
||||
}
|
||||
|
||||
// readTaggingProxyTargets authorizes all present replicas before returning any
|
||||
// tags or allowing any mutation. Missing replicas may simply lag replication;
|
||||
// unreadable replicas must still fail closed.
|
||||
func readTaggingProxyTargets(ctx context.Context, bucket, object string, opts ObjectOptions, proxyTargets *madmin.BucketTargets, authorize func(string, error) APIErrorCode) ([]taggingProxyTarget, proxyResult, APIErrorCode) {
|
||||
var targets []taggingProxyTarget
|
||||
for _, target := range proxyTargets.Targets {
|
||||
tgt := globalBucketTargetSys.GetRemoteTargetClient(bucket, target.Arn)
|
||||
if tgt == nil || globalBucketTargetSys.isOffline(tgt.EndpointURL()) || tgt.disableProxy {
|
||||
continue
|
||||
}
|
||||
targets = append(targets, taggingProxyTarget{client: tgt})
|
||||
}
|
||||
if len(targets) == 0 {
|
||||
err := RemoteTargetConnectionErr{Bucket: bucket, Err: errors.New("no available remote replication target")}
|
||||
return nil, proxyResult{Err: err}, authorize("", err)
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errs := make([]error, len(proxyTargets.Targets))
|
||||
for idx, t := range proxyTargets.Targets {
|
||||
tgt := globalBucketTargetSys.GetRemoteTargetClient(bucket, t.Arn)
|
||||
if tgt == nil || globalBucketTargetSys.isOffline(tgt.EndpointURL()) {
|
||||
continue
|
||||
}
|
||||
// if proxying explicitly disabled on remote target
|
||||
if tgt.disableProxy {
|
||||
continue
|
||||
}
|
||||
idx := idx
|
||||
wg.Add(1)
|
||||
go func(idx int, tgt *TargetClient) {
|
||||
defer wg.Done()
|
||||
var err error
|
||||
if tags != nil {
|
||||
popts := minio.PutObjectTaggingOptions{
|
||||
errs := make([]error, len(targets))
|
||||
for i := range targets {
|
||||
wg.Go(func() {
|
||||
tgt := &targets[i]
|
||||
tgt.tags, errs[i] = tgt.client.GetObjectTagging(ctx, tgt.client.Bucket, object, minio.GetObjectTaggingOptions{
|
||||
VersionID: opts.VersionID,
|
||||
Internal: minio.AdvancedObjectTaggingOptions{
|
||||
ReplicationProxyRequest: "true",
|
||||
},
|
||||
}
|
||||
err = tgt.PutObjectTagging(ctx, tgt.Bucket, object, tags, popts)
|
||||
} else {
|
||||
dopts := minio.RemoveObjectTaggingOptions{
|
||||
VersionID: opts.VersionID,
|
||||
Internal: minio.AdvancedObjectTaggingOptions{
|
||||
ReplicationProxyRequest: "true",
|
||||
},
|
||||
}
|
||||
err = tgt.RemoveObjectTagging(ctx, tgt.Bucket, object, dopts)
|
||||
}
|
||||
if err != nil {
|
||||
errs[idx] = err
|
||||
}
|
||||
}(idx, tgt)
|
||||
Internal: minio.AdvancedObjectTaggingOptions{ReplicationProxyRequest: "true"},
|
||||
})
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
var (
|
||||
terr error
|
||||
taggedCount int
|
||||
)
|
||||
var firstNotFound, readErr error
|
||||
for _, err := range errs {
|
||||
if err == nil {
|
||||
taggedCount++
|
||||
switch {
|
||||
case err == nil:
|
||||
case isRemoteTaggingNotFound(err):
|
||||
if firstNotFound == nil {
|
||||
firstNotFound = err
|
||||
}
|
||||
default:
|
||||
if code := authorize("", err); code != ErrNone {
|
||||
return nil, proxyResult{}, code
|
||||
}
|
||||
if readErr == nil {
|
||||
readErr = err
|
||||
}
|
||||
}
|
||||
}
|
||||
present := make([]taggingProxyTarget, 0, len(targets))
|
||||
for i, tgt := range targets {
|
||||
if errs[i] != nil {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
terr = err
|
||||
if tgt.tags == nil {
|
||||
tgt.tags, _ = tags.MapToObjectTags(nil)
|
||||
}
|
||||
if code := authorize(tgt.tags.String(), nil); code != ErrNone {
|
||||
return nil, proxyResult{}, code
|
||||
}
|
||||
// don't return error if at least one target was tagged successfully
|
||||
if taggedCount == 0 && terr != nil {
|
||||
proxy.Err = terr
|
||||
present = append(present, tgt)
|
||||
}
|
||||
return proxy
|
||||
if readErr != nil {
|
||||
return nil, proxyResult{Err: readErr}, ErrNone
|
||||
}
|
||||
if len(present) == 0 {
|
||||
// With no object anywhere, authorize the empty tag set once before
|
||||
// disclosing its absence. No write RPC can help a missing replica.
|
||||
return nil, proxyResult{Err: firstNotFound}, authorize("", firstNotFound)
|
||||
}
|
||||
return present, proxyResult{}, ErrNone
|
||||
}
|
||||
|
||||
// proxyTaggingToRepTarget proxies tagging requests to remote targets for
|
||||
// active-active replicated setups.
|
||||
func proxyTaggingToRepTarget(ctx context.Context, bucket, object string, tags *tags.Tags, opts ObjectOptions, proxyTargets *madmin.BucketTargets, authorize func(string, error) APIErrorCode) (proxy proxyResult, authErr APIErrorCode) {
|
||||
if opts.ProxyRequest || (opts.ProxyHeaderSet && !opts.ProxyRequest) {
|
||||
return proxy, ErrNone
|
||||
}
|
||||
targets, proxy, authErr := readTaggingProxyTargets(ctx, bucket, object, opts, proxyTargets, authorize)
|
||||
if authErr != ErrNone || proxy.Err != nil {
|
||||
return proxy, authErr
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
writeErrs := make([]error, len(targets))
|
||||
for i, target := range targets {
|
||||
wg.Go(func() {
|
||||
tgt := target.client
|
||||
if tags != nil {
|
||||
writeErrs[i] = tgt.PutObjectTagging(ctx, tgt.Bucket, object, tags, minio.PutObjectTaggingOptions{
|
||||
VersionID: opts.VersionID,
|
||||
Internal: minio.AdvancedObjectTaggingOptions{ReplicationProxyRequest: "true"},
|
||||
})
|
||||
} else {
|
||||
writeErrs[i] = tgt.RemoveObjectTagging(ctx, tgt.Bucket, object, minio.RemoveObjectTaggingOptions{
|
||||
VersionID: opts.VersionID,
|
||||
Internal: minio.AdvancedObjectTaggingOptions{ReplicationProxyRequest: "true"},
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
var lastErr error
|
||||
for _, err := range writeErrs {
|
||||
if err == nil {
|
||||
return proxy, ErrNone
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
return proxyResult{Err: lastErr}, ErrNone
|
||||
}
|
||||
|
||||
// proxyGetTaggingToRepTarget proxies get tagging requests to remote targets for
|
||||
// active-active replicated setups
|
||||
func proxyGetTaggingToRepTarget(ctx context.Context, bucket, object string, opts ObjectOptions, proxyTargets *madmin.BucketTargets) (tgs *tags.Tags, proxy proxyResult) {
|
||||
// this option is set when active-active replication is in place between site A -> B,
|
||||
// and request hits site B that does not have the object yet.
|
||||
if opts.ProxyRequest || (opts.ProxyHeaderSet && !opts.ProxyRequest) { // true only when site B sets MinIOSourceProxyRequest header
|
||||
return nil, proxy
|
||||
// active-active replicated setups.
|
||||
func proxyGetTaggingToRepTarget(ctx context.Context, bucket, object string, opts ObjectOptions, proxyTargets *madmin.BucketTargets, authorize func(string, error) APIErrorCode) (tgs *tags.Tags, proxy proxyResult, authErr APIErrorCode) {
|
||||
if opts.ProxyRequest || (opts.ProxyHeaderSet && !opts.ProxyRequest) {
|
||||
return nil, proxy, ErrNone
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
errs := make([]error, len(proxyTargets.Targets))
|
||||
tagSlc := make([]map[string]string, len(proxyTargets.Targets))
|
||||
for idx, t := range proxyTargets.Targets {
|
||||
tgt := globalBucketTargetSys.GetRemoteTargetClient(bucket, t.Arn)
|
||||
if tgt == nil || globalBucketTargetSys.isOffline(tgt.EndpointURL()) {
|
||||
continue
|
||||
targets, proxy, authErr := readTaggingProxyTargets(ctx, bucket, object, opts, proxyTargets, authorize)
|
||||
if authErr != ErrNone || proxy.Err != nil {
|
||||
return nil, proxy, authErr
|
||||
}
|
||||
// if proxying explicitly disabled on remote target
|
||||
if tgt.disableProxy {
|
||||
continue
|
||||
}
|
||||
idx := idx
|
||||
wg.Add(1)
|
||||
go func(idx int, tgt *TargetClient) {
|
||||
defer wg.Done()
|
||||
var err error
|
||||
gopts := minio.GetObjectTaggingOptions{
|
||||
VersionID: opts.VersionID,
|
||||
Internal: minio.AdvancedObjectTaggingOptions{
|
||||
ReplicationProxyRequest: "true",
|
||||
},
|
||||
}
|
||||
tgs, err = tgt.GetObjectTagging(ctx, tgt.Bucket, object, gopts)
|
||||
if err != nil {
|
||||
errs[idx] = err
|
||||
} else {
|
||||
tagSlc[idx] = tgs.ToMap()
|
||||
}
|
||||
}(idx, tgt)
|
||||
}
|
||||
wg.Wait()
|
||||
for idx, err := range errs {
|
||||
errCode := minio.ToErrorResponse(err).Code
|
||||
if err != nil && errCode != "NoSuchKey" && errCode != "NoSuchVersion" {
|
||||
return nil, proxyResult{Err: err}
|
||||
}
|
||||
if err == nil {
|
||||
tgs, _ = tags.MapToObjectTags(tagSlc[idx])
|
||||
}
|
||||
}
|
||||
if len(errs) == 1 {
|
||||
proxy.Err = errs[0]
|
||||
}
|
||||
return tgs, proxy
|
||||
return targets[0].tags, proxy, ErrNone
|
||||
}
|
||||
|
||||
func scheduleReplicationDelete(ctx context.Context, dv DeletedObjectReplicationInfo, o ObjectLayer) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
// Copyright (c) 2026 PGSTY
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
minio "github.com/minio/minio-go/v7"
|
||||
"github.com/minio/minio/internal/bucket/replication"
|
||||
xhttp "github.com/minio/minio/internal/http"
|
||||
"github.com/minio/mux"
|
||||
)
|
||||
|
||||
// Schedule a protection update immediately before the storage mutation. Both
|
||||
// public delete entry points are gated so the test is independent of whether
|
||||
// the handler batches a version or uses the single-object deletion path.
|
||||
type bulkDeleteLockBarrier struct {
|
||||
ObjectLayer
|
||||
entered chan struct{}
|
||||
release chan struct{}
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func (g *bulkDeleteLockBarrier) DeleteObjects(ctx context.Context, bucket string, objects []ObjectToDelete, opts ObjectOptions) ([]DeletedObject, []error) {
|
||||
g.wait()
|
||||
return g.ObjectLayer.DeleteObjects(ctx, bucket, objects, opts)
|
||||
}
|
||||
|
||||
func (g *bulkDeleteLockBarrier) wait() {
|
||||
g.once.Do(func() { close(g.entered); <-g.release })
|
||||
}
|
||||
|
||||
func (g *bulkDeleteLockBarrier) DeleteObject(ctx context.Context, bucket, object string, opts ObjectOptions) (ObjectInfo, error) {
|
||||
g.wait()
|
||||
return g.ObjectLayer.DeleteObject(ctx, bucket, object, opts)
|
||||
}
|
||||
|
||||
func bulkDeleteLockRequest(t *testing.T, bucket, key, version string) *http.Request {
|
||||
t.Helper()
|
||||
body := encodeResponse(DeleteObjectsRequest{Objects: []ObjectToDelete{{ObjectV: ObjectV{ObjectName: key, VersionID: version}}}})
|
||||
req, err := newTestSignedRequestV4(http.MethodPost, getDeleteMultipleObjectsURL("", bucket), int64(len(body)), bytes.NewReader(body), globalActiveCred.AccessKey, globalActiveCred.SecretKey, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return mux.SetURLVars(req, map[string]string{"bucket": bucket})
|
||||
}
|
||||
|
||||
func TestBulkDeleteObjectLockInterleaving(t *testing.T) {
|
||||
for _, pools := range []int{1, 2} {
|
||||
for _, directory := range []bool{false, true} {
|
||||
for _, kind := range []string{"legal-hold", "retention"} {
|
||||
t.Run(fmt.Sprintf("pools=%d/directory=%v/%s", pools, directory, kind), func(t *testing.T) {
|
||||
z, _ := bulkDeletePools(t, pools)
|
||||
bucket, router, err := initAPIHandlerTest(t.Context(), z, nil, MakeBucketOptions{VersioningEnabled: true, LockEnabled: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key := "protection-update"
|
||||
if directory {
|
||||
key += "/"
|
||||
}
|
||||
var oi ObjectInfo
|
||||
if directory {
|
||||
oi, err = z.PutObject(t.Context(), bucket, key, mustGetPutObjReader(t, bytes.NewReader(nil), 0, "", ""), ObjectOptions{Versioned: true, VersionID: nullVersionID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
} else {
|
||||
oi = batchPutVersion(t, z, bucket, key, nil)
|
||||
}
|
||||
gate := &bulkDeleteLockBarrier{ObjectLayer: z, entered: make(chan struct{}), release: make(chan struct{})}
|
||||
api := objectAPIHandlers{ObjectAPI: func() ObjectLayer { return gate }}
|
||||
requestVersion := oi.VersionID
|
||||
if directory {
|
||||
requestVersion = ""
|
||||
}
|
||||
req := bulkDeleteLockRequest(t, bucket, key, requestVersion)
|
||||
rec := httptest.NewRecorder()
|
||||
done := make(chan struct{})
|
||||
go func() { defer close(done); api.DeleteMultipleObjectsHandler(rec, req) }()
|
||||
var release sync.Once
|
||||
unblock := func() { release.Do(func() { close(gate.release) }) }
|
||||
defer func() {
|
||||
unblock()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Error("delete did not finish")
|
||||
}
|
||||
}()
|
||||
select {
|
||||
case <-gate.entered:
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("bulk handler did not reach deletion after validation")
|
||||
}
|
||||
body := []byte(`<LegalHold xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Status>ON</Status></LegalHold>`)
|
||||
if kind == "retention" {
|
||||
body = retentionBody("COMPLIANCE", UTCNow().Add(24*time.Hour))
|
||||
}
|
||||
holdReq, err := newTestSignedRequestV4(http.MethodPut, "/"+bucket+"/"+key+"?"+kind+"&versionId="+url.QueryEscape(oi.VersionID), int64(len(body)), bytes.NewReader(body), globalActiveCred.AccessKey, globalActiveCred.SecretKey, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
holdRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(holdRec, holdReq)
|
||||
protected, getErr := z.GetObjectInfo(t.Context(), bucket, key, ObjectOptions{VersionID: oi.VersionID})
|
||||
unblock()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("delete did not finish")
|
||||
}
|
||||
if holdRec.Code != http.StatusOK || getErr != nil {
|
||||
t.Fatalf("protection setup failed: status=%d body=%s err=%v", holdRec.Code, holdRec.Body.String(), getErr)
|
||||
}
|
||||
if kind == "legal-hold" && protected.UserDefined[strings.ToLower(xhttp.AmzObjectLockLegalHold)] != "ON" {
|
||||
t.Fatal("legal hold was not stored")
|
||||
}
|
||||
if kind == "retention" && protected.UserDefined[strings.ToLower(xhttp.AmzObjectLockMode)] != "COMPLIANCE" {
|
||||
t.Fatal("retention was not stored")
|
||||
}
|
||||
var response DeleteObjectsResponse
|
||||
if err := xml.Unmarshal(rec.Body.Bytes(), &response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
exists := batchVersionExists(t, z, bucket, key, oi.VersionID)
|
||||
t.Logf("protection PUT=%d; delete POST=%d response=%s; protected_version_exists=%v", holdRec.Code, rec.Code, rec.Body.String(), exists)
|
||||
if rec.Code != http.StatusOK || !exists || len(response.Errors) != 1 || len(response.DeletedObjects) != 0 {
|
||||
t.Errorf("bulk deletion removed a version after its protection update succeeded")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBulkDeleteObjectLockPoolCopies(t *testing.T) {
|
||||
for _, kind := range []string{"COMPLIANCE", "LEGALHOLD"} {
|
||||
t.Run(kind, func(t *testing.T) {
|
||||
z, _ := bulkDeletePools(t, 2)
|
||||
bucket, router, err := initAPIHandlerTest(t.Context(), z, nil, MakeBucketOptions{VersioningEnabled: true, LockEnabled: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, bulk := range []bool{false, true} {
|
||||
key := "single-control"
|
||||
if bulk {
|
||||
key = "bulk"
|
||||
}
|
||||
id := mustGetUUID()
|
||||
mtime := UTCNow().Add(-time.Hour)
|
||||
putConsistencyObject(t, z, bucket, key, 0, "same-data", ObjectOptions{Versioned: true, VersionID: id, MTime: mtime, UserDefined: nil})
|
||||
putConsistencyObject(t, z, bucket, key, 1, "same-data", ObjectOptions{Versioned: true, VersionID: id, MTime: mtime, UserDefined: batchLockMeta(kind)})
|
||||
selected, err := z.GetObjectInfo(t.Context(), bucket, key, ObjectOptions{VersionID: id})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if selected.UserDefined[strings.ToLower(xhttp.AmzObjectLockMode)] != "" || selected.UserDefined[strings.ToLower(xhttp.AmzObjectLockLegalHold)] != "" {
|
||||
t.Fatal("fixture did not select the unprotected pool copy")
|
||||
}
|
||||
req := bulkDeleteLockRequest(t, bucket, key, id)
|
||||
if !bulk {
|
||||
req, err = newTestSignedRequestV4(http.MethodDelete, getDeleteObjectURL("", bucket, key)+"?versionId="+id, 0, nil, globalActiveCred.AccessKey, globalActiveCred.SecretKey, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if !strings.Contains(rec.Body.String(), "Object is WORM protected") {
|
||||
t.Fatalf("expected Object Lock refusal: HTTP %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
remaining := 0
|
||||
for _, pool := range z.serverPools {
|
||||
_, err := pool.GetObjectInfo(t.Context(), bucket, key, ObjectOptions{VersionID: id})
|
||||
if err == nil {
|
||||
remaining++
|
||||
} else if !isErrObjectNotFound(err) && !isErrVersionNotFound(err) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
t.Logf("bulk=%v HTTP=%d response=%s remaining_pool_copies=%d", bulk, rec.Code, rec.Body.String(), remaining)
|
||||
if remaining != 2 {
|
||||
t.Errorf("protected version copies removed by bulk=%v", bulk)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// One response must retain the identity and outcome of every requested item,
|
||||
// even when physical version removals and marker creation take different paths.
|
||||
func TestBulkDeleteObjectLockMixedResponse(t *testing.T) {
|
||||
for _, pools := range []int{1, 2} {
|
||||
for _, quiet := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("pools=%d/quiet=%v", pools, quiet), func(t *testing.T) {
|
||||
z, _ := bulkDeletePools(t, pools)
|
||||
bucket, router, err := initAPIHandlerTest(t.Context(), z, nil, MakeBucketOptions{VersioningEnabled: true, LockEnabled: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
locked := batchPutVersion(t, z, bucket, "locked", batchLockMeta("COMPLIANCE"))
|
||||
free := batchPutVersion(t, z, bucket, "free", nil)
|
||||
marker := batchPutVersion(t, z, bucket, "marker", batchLockMeta("LEGALHOLD"))
|
||||
if _, err := z.PutObject(t.Context(), bucket, "directory/", mustGetPutObjReader(t, bytes.NewReader(nil), 0, "", ""), ObjectOptions{Versioned: true, VersionID: nullVersionID}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
missing := mustGetUUID()
|
||||
objects := []ObjectToDelete{
|
||||
{ObjectV: ObjectV{ObjectName: "locked", VersionID: locked.VersionID}},
|
||||
{ObjectV: ObjectV{ObjectName: "free", VersionID: free.VersionID}},
|
||||
{ObjectV: ObjectV{ObjectName: "missing", VersionID: missing}},
|
||||
{ObjectV: ObjectV{ObjectName: "marker"}},
|
||||
{ObjectV: ObjectV{ObjectName: "directory/"}},
|
||||
{ObjectV: ObjectV{ObjectName: "directory/", VersionID: nullVersionID}},
|
||||
{ObjectV: ObjectV{ObjectName: "invalid", VersionID: "invalid"}},
|
||||
}
|
||||
objects = append(objects, objects[0], objects[1])
|
||||
body := encodeResponse(DeleteObjectsRequest{Quiet: quiet, Objects: objects})
|
||||
rec := r5Request(t, router, globalActiveCred, http.MethodPost, getDeleteMultipleObjectsURL("", bucket), string(body), nil)
|
||||
var response DeleteObjectsResponse
|
||||
if rec.Code != http.StatusOK || xml.Unmarshal(rec.Body.Bytes(), &response) != nil {
|
||||
t.Fatalf("HTTP %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
wantErrors := []ObjectV{objects[0].ObjectV, objects[6].ObjectV, objects[0].ObjectV}
|
||||
if len(response.Errors) != len(wantErrors) {
|
||||
t.Fatalf("errors: %+v", response.Errors)
|
||||
}
|
||||
for i, e := range response.Errors {
|
||||
if e.Key != wantErrors[i].ObjectName || e.VersionID != wantErrors[i].VersionID {
|
||||
t.Errorf("error identity %d: %+v", i, e)
|
||||
}
|
||||
}
|
||||
if quiet {
|
||||
if len(response.DeletedObjects) != 0 {
|
||||
t.Errorf("quiet successes: %+v", response.DeletedObjects)
|
||||
}
|
||||
} else {
|
||||
want := []ObjectV{objects[1].ObjectV, objects[2].ObjectV, objects[3].ObjectV, objects[4].ObjectV, objects[5].ObjectV, objects[1].ObjectV}
|
||||
if len(response.DeletedObjects) != len(want) {
|
||||
t.Fatalf("successes: %+v", response.DeletedObjects)
|
||||
}
|
||||
for i, d := range response.DeletedObjects {
|
||||
if d.ObjectName != want[i].ObjectName || d.VersionID != want[i].VersionID {
|
||||
t.Errorf("success identity %d: %+v", i, d)
|
||||
}
|
||||
if i == 2 && (!d.DeleteMarker || d.DeleteMarkerVersionID == "") {
|
||||
t.Errorf("missing delete marker: %+v", d)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !batchVersionExists(t, z, bucket, "locked", locked.VersionID) || !batchVersionExists(t, z, bucket, "marker", marker.VersionID) || batchVersionExists(t, z, bucket, "free", free.VersionID) || batchVersionExists(t, z, bucket, "directory/", nullVersionID) {
|
||||
t.Fatal("unexpected stored versions after mixed deletion")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBulkDeleteObjectLockGovernance(t *testing.T) {
|
||||
for _, pools := range []int{1, 2} {
|
||||
t.Run(fmt.Sprintf("pools=%d", pools), func(t *testing.T) {
|
||||
z, _ := bulkDeletePools(t, pools)
|
||||
bucket, router, err := initAPIHandlerTest(t.Context(), z, nil, MakeBucketOptions{VersioningEnabled: true, LockEnabled: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deleteOnly := newObjectAttributesAuthzUser(t, "bulk-governance", bucket, `"s3:DeleteObjectVersion"`)
|
||||
for _, allowed := range []bool{false, true} {
|
||||
for _, bypass := range []bool{false, true} {
|
||||
key := fmt.Sprintf("allowed-%v-bypass-%v", allowed, bypass)
|
||||
oi := batchPutVersion(t, z, bucket, key, batchLockMeta("GOVERNANCE"))
|
||||
cred := deleteOnly
|
||||
if allowed {
|
||||
cred = globalActiveCred
|
||||
}
|
||||
body := encodeResponse(DeleteObjectsRequest{Objects: []ObjectToDelete{{ObjectV: ObjectV{ObjectName: key, VersionID: oi.VersionID}}}})
|
||||
header := map[string]string{}
|
||||
if bypass {
|
||||
header[xhttp.AmzObjectLockBypassGovernance] = "true"
|
||||
}
|
||||
rec := r5Request(t, router, cred, http.MethodPost, getDeleteMultipleObjectsURL("", bucket), string(body), header)
|
||||
var response DeleteObjectsResponse
|
||||
if rec.Code != http.StatusOK || xml.Unmarshal(rec.Body.Bytes(), &response) != nil {
|
||||
t.Fatalf("HTTP %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
deleted := allowed && bypass
|
||||
if batchVersionExists(t, z, bucket, key, oi.VersionID) == deleted || (len(response.Errors) == 0) != deleted {
|
||||
t.Fatalf("%s: %s", key, rec.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The locked path computes replication decisions while holding the mutation
|
||||
// lock. Keep pending purges and their queue entries, including duplicate input.
|
||||
func TestBulkDeleteObjectLockReplication(t *testing.T) {
|
||||
for _, pools := range []int{1, 2} {
|
||||
t.Run(fmt.Sprintf("pools=%d", pools), func(t *testing.T) {
|
||||
z, _ := bulkDeletePools(t, pools)
|
||||
bucket, router, err := initAPIHandlerTest(t.Context(), z, nil, MakeBucketOptions{VersioningEnabled: true, LockEnabled: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
client, err := minio.New("localhost:1", &minio.Options{Region: "us-east-1", MaxRetries: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
worker, cleanup := r5ReplicationFixture(t, z, bucket, client)
|
||||
defer cleanup()
|
||||
free := batchPutVersion(t, z, bucket, "free", nil)
|
||||
locked := batchPutVersion(t, z, bucket, "locked", batchLockMeta("LEGALHOLD"))
|
||||
objects := []ObjectToDelete{{ObjectV: ObjectV{ObjectName: "free", VersionID: free.VersionID}}, {ObjectV: ObjectV{ObjectName: "locked", VersionID: locked.VersionID}}}
|
||||
objects = append(objects, objects[0])
|
||||
body := encodeResponse(DeleteObjectsRequest{Objects: objects})
|
||||
rec := r5Request(t, router, globalActiveCred, http.MethodPost, getDeleteMultipleObjectsURL("", bucket), string(body), nil)
|
||||
var response DeleteObjectsResponse
|
||||
if rec.Code != http.StatusOK || xml.Unmarshal(rec.Body.Bytes(), &response) != nil || len(response.Errors) != 1 || len(response.DeletedObjects) != 2 {
|
||||
t.Fatalf("HTTP %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if len(worker) != 1 {
|
||||
t.Fatalf("want one purge queued, got %d", len(worker))
|
||||
}
|
||||
op, ok := (<-worker).(DeletedObjectReplicationInfo)
|
||||
if !ok || op.ObjectName != "free" || op.VersionID != free.VersionID || op.VersionPurgeStatus() != replication.VersionPurgePending || op.ReplicationState.ReplicateDecisionStr == "" {
|
||||
t.Fatalf("lost purge identity/state: %+v", op)
|
||||
}
|
||||
if !batchVersionExists(t, z, bucket, "locked", locked.VersionID) {
|
||||
t.Fatal("protected version was deleted")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// Copyright (c) 2026 PGSTY
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/minio/minio/internal/auth"
|
||||
)
|
||||
|
||||
// A directory object always has the null version, and a bulk delete without a
|
||||
// version ID removes it. The retention check must read that version's lock.
|
||||
func TestBulkDeleteDirectoryObjectLock(t *testing.T) {
|
||||
defer DetectTestLeak(t)()
|
||||
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
|
||||
t: t, endpoints: []string{"DeleteMultipleObjects"},
|
||||
objAPITest: func(obj ObjectLayer, instance, bucket string, router http.Handler, cred auth.Credentials, t *testing.T) {
|
||||
enableBucketObjectLock(t, bucket)
|
||||
for _, kind := range append([]string{"unlocked"}, batchLockKinds...) {
|
||||
key := "dir-" + kind + "/"
|
||||
if _, err := obj.PutObject(t.Context(), bucket, key, mustGetPutObjReader(t, bytes.NewReader(nil), 0, "", ""),
|
||||
ObjectOptions{Versioned: true, VersionID: nullVersionID, UserDefined: batchLockMeta(kind)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := encodeResponse(DeleteObjectsRequest{Objects: []ObjectToDelete{{ObjectV: ObjectV{ObjectName: key}}}})
|
||||
req, err := newTestSignedRequestV4(http.MethodPost, getDeleteMultipleObjectsURL("", bucket), int64(len(body)), bytes.NewReader(body),
|
||||
cred.AccessKey, cred.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: %s: status %d: %s", instance, key, rec.Code, rec.Body.String())
|
||||
}
|
||||
exists := batchVersionExists(t, obj, bucket, key, nullVersionID)
|
||||
if kind == "unlocked" {
|
||||
if exists || len(resp.DeletedObjects) != 1 {
|
||||
t.Errorf("%s: %s: unlocked directory object was not deleted: %s", instance, key, rec.Body.String())
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !exists || len(resp.Errors) != 1 || len(resp.DeletedObjects) != 0 ||
|
||||
resp.Errors[0].Key != key || !strings.Contains(resp.Errors[0].Message, "WORM") {
|
||||
t.Errorf("%s: %s: want the WORM error and the object kept (exists=%v): %s", instance, key, exists, rec.Body.String())
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1722,6 +1722,9 @@ func (er erasureObjects) CompleteMultipartUpload(ctx context.Context, bucket str
|
||||
}
|
||||
}
|
||||
|
||||
if err := er.checkReplicaContent(ctx, bucket, object, minioMetaMultipartBucket, uploadIDPath, partsMetadata, onlineDisks, opts); err != nil {
|
||||
return oi, toObjectErr(err, bucket, object)
|
||||
}
|
||||
er.cleanupMultipartPath(ctx, paths...) // cleanup all part.N.meta, and skipped part.N's before final rename().
|
||||
|
||||
defer func() {
|
||||
|
||||
+34
-3
@@ -133,8 +133,25 @@ func (er erasureObjects) CopyObject(ctx context.Context, srcBucket, srcObject, d
|
||||
}
|
||||
|
||||
if dstOpts.ReplicaLockReconcile {
|
||||
reconcileStoredObjectLock(srcInfo.UserDefined, storedObjectLockState(fi.Metadata))
|
||||
reconcileStoredObjectTags(srcInfo.UserDefined, fi.Metadata[xhttp.AmzObjectTagging], fi.Metadata[ReservedMetadataPrefixLower+TaggingTimestamp])
|
||||
stored := fi.ToObjectInfo(dstBucket, dstObject, dstOpts.Versioned || dstOpts.VersionSuspended)
|
||||
if srcInfo.versionOnly || dstOpts.replicaObjectInfo != nil {
|
||||
// fi describes the source of the new reference, not the version
|
||||
// being replaced. A pooled lookup also preserves protection merged
|
||||
// from another copy, even when this set holds a newer weak revision.
|
||||
getInfo := er.getObjectInfo
|
||||
if dstOpts.replicaObjectInfo != nil {
|
||||
getInfo = dstOpts.replicaObjectInfo
|
||||
}
|
||||
stored, err = replicaStoredObjectInfo(ctx, func(ctx context.Context, bucket, object string, opts ObjectOptions) (ObjectInfo, error) {
|
||||
opts.NoLock = true
|
||||
return getInfo(ctx, bucket, object, opts)
|
||||
}, dstBucket, dstObject, dstOpts.VersionID)
|
||||
if err != nil {
|
||||
return oi, err
|
||||
}
|
||||
}
|
||||
reconcileStoredObjectLock(srcInfo.UserDefined, storedObjectLockState(stored.UserDefined))
|
||||
reconcileStoredObjectTags(srcInfo.UserDefined, stored.UserTags, stored.UserDefined[ReservedMetadataPrefixLower+TaggingTimestamp])
|
||||
}
|
||||
|
||||
filterOnlineDisksInplace(fi, metaArr, onlineDisks)
|
||||
@@ -148,6 +165,17 @@ func (er erasureObjects) CopyObject(ctx context.Context, srcBucket, srcObject, d
|
||||
fi.IsLatest = true // we are creating a new version so this is latest.
|
||||
}
|
||||
}
|
||||
if srcInfo.versionOnly && (dstOpts.ReplicaLockReconcile || dstOpts.ReplicationRequest) && !dstOpts.DataMovement {
|
||||
candidate := slices.Clone(metaArr)
|
||||
for i := range candidate {
|
||||
candidate[i].VersionID = versionID
|
||||
candidate[i].Metadata = srcInfo.UserDefined
|
||||
}
|
||||
readDisks, readMeta := shuffleDisksAndPartsMetadataByIndex(onlineDisks, candidate, fi)
|
||||
if err := er.checkReplicaContent(ctx, dstBucket, dstObject, srcBucket, srcObject, readMeta, readDisks, dstOpts); err != nil {
|
||||
return oi, err
|
||||
}
|
||||
}
|
||||
|
||||
if !dstOpts.ReplicationRequest && !dstOpts.ReplicaLockReconcile && !dstOpts.DataMovement &&
|
||||
(versionID == "" || versionID == nullVersionID) {
|
||||
@@ -1722,6 +1750,9 @@ func (er erasureObjects) putObject(ctx context.Context, bucket string, object st
|
||||
}
|
||||
|
||||
// Rename the successfully written temporary object to final location.
|
||||
if err := er.checkReplicaContent(ctx, bucket, object, minioMetaTmpBucket, tempObj, partsMetadata, onlineDisks, opts); err != nil {
|
||||
return ObjectInfo{}, toObjectErr(err, bucket, object)
|
||||
}
|
||||
onlineDisks, versions, oldDataDir, err := renameData(ctx, onlineDisks, minioMetaTmpBucket, tempObj, partsMetadata, bucket, object, writeQuorum)
|
||||
if err != nil {
|
||||
if errors.Is(err, errFileNotFound) {
|
||||
@@ -2560,7 +2591,7 @@ func (er erasureObjects) PutObjectTags(ctx context.Context, bucket, object strin
|
||||
|
||||
fi.Metadata[xhttp.AmzObjectTagging] = tags
|
||||
fi.ReplicationState = opts.PutReplicationState()
|
||||
stamp := monotonicTaggingTimestamp(opts.UserDefined[ReservedMetadataPrefixLower+TaggingTimestamp], fi.Metadata[ReservedMetadataPrefixLower+TaggingTimestamp])
|
||||
stamp := monotonicMetadataTimestamp(opts.UserDefined[ReservedMetadataPrefixLower+TaggingTimestamp], fi.Metadata[ReservedMetadataPrefixLower+TaggingTimestamp])
|
||||
maps.Copy(fi.Metadata, opts.UserDefined)
|
||||
if stamp != "" {
|
||||
fi.Metadata[ReservedMetadataPrefixLower+TaggingTimestamp] = stamp
|
||||
|
||||
@@ -271,11 +271,33 @@ func (z *erasureServerPools) applyMigrationExpiry(ctx context.Context, bucket, o
|
||||
// in the source until the ordinary expiry path has deleted them: that path
|
||||
// owns replication state and tier cleanup, and it re-evaluates the current
|
||||
// lifecycle rule. A migration cleanup must never turn an asynchronous ILM
|
||||
// decision into an unconditional physical purge.
|
||||
func (z *erasureServerPools) removeMovedVersions(ctx context.Context, bucket, object string, source int, versions []FileInfo, expired []migrationExpiredVersion) error {
|
||||
// decision into an unconditional physical purge. It returns how many source
|
||||
// versions were removed, including versions deleted by ILM before cleanup.
|
||||
func (z *erasureServerPools) removeMovedVersions(ctx context.Context, bucket, object string, source int, versions []FileInfo, expired []migrationExpiredVersion) (int, error) {
|
||||
err := z.retireMovedVersions(ctx, bucket, object, source, versions, expired)
|
||||
removed, countErr := z.countAbsentMigrationVersions(ctx, bucket, object, source, versions)
|
||||
if ctx.Err() != nil {
|
||||
return 0, ctx.Err()
|
||||
}
|
||||
if err != nil {
|
||||
return removed, err
|
||||
}
|
||||
return removed, countErr
|
||||
}
|
||||
|
||||
// retireMovedVersions does not infer physical progress from a pool-level read
|
||||
// or delete result. Decommission verifies the entire walk snapshot once after
|
||||
// this cleanup and tier GC, including versions concurrently deleted by clients.
|
||||
func (z *erasureServerPools) retireMovedVersions(ctx context.Context, bucket, object string, source int, versions []FileInfo, expired []migrationExpiredVersion) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := z.applyMigrationExpiry(ctx, bucket, object, source, expired); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(versions) == 0 {
|
||||
return nil
|
||||
}
|
||||
object = encodeDirObject(object)
|
||||
lk := z.NewNSLock(bucket, object)
|
||||
lkctx, err := lk.GetLock(ctx, globalOperationTimeout)
|
||||
@@ -289,6 +311,7 @@ func (z *erasureServerPools) removeMovedVersions(ctx context.Context, bucket, ob
|
||||
if vid == "" {
|
||||
vid = nullVersionID
|
||||
}
|
||||
expiredVersion := isExpiredVersion(expired, version)
|
||||
copies, err := z.objectPoolInfos(ctx, bucket, object, ObjectOptions{VersionID: vid, NoAuditLog: true})
|
||||
if isErrObjectNotFound(err) || isErrVersionNotFound(err) {
|
||||
continue
|
||||
@@ -311,7 +334,7 @@ func (z *erasureServerPools) removeMovedVersions(ctx context.Context, bucket, ob
|
||||
if !src.ModTime.Equal(version.ModTime) || src.DeleteMarker != version.Deleted {
|
||||
return PreConditionFailed{}
|
||||
}
|
||||
if isExpiredVersion(expired, version) {
|
||||
if expiredVersion {
|
||||
// The logical expiry path owns this version. Keeping the source is
|
||||
// also the retry point if that path failed or the pool check races it.
|
||||
continue
|
||||
@@ -370,19 +393,7 @@ func (z *erasureServerPools) healObjectInPool(ctx context.Context, er *erasureOb
|
||||
// mergePoolLockState resolves each independently ordered field. An ordered
|
||||
// removal is a value in its own right; an absent unordered field is not one.
|
||||
func mergePoolLockState(copies []PoolObjInfo) objectLockState {
|
||||
state := storedObjectLockState(copies[0].ObjInfo.UserDefined)
|
||||
for _, copy := range copies[1:] {
|
||||
next := storedObjectLockState(copy.ObjInfo.UserDefined)
|
||||
retentionTime, _ := time.Parse(time.RFC3339Nano, next.retentionTimestamp)
|
||||
if state.retentionIsOlderThan(retentionTime) || (state.retentionTimestamp == "" && state.mode == "" && next.mode != "") {
|
||||
state.mode, state.retainUntil, state.retentionTimestamp = next.mode, next.retainUntil, next.retentionTimestamp
|
||||
}
|
||||
holdTime, _ := time.Parse(time.RFC3339Nano, next.legalHoldTimestamp)
|
||||
if state.legalHoldIsOlderThan(holdTime) || (state.legalHoldTimestamp == "" && state.legalHold == "" && next.legalHold != "") {
|
||||
state.legalHold, state.legalHoldTimestamp = next.legalHold, next.legalHoldTimestamp
|
||||
}
|
||||
}
|
||||
return state
|
||||
return reducePoolLockState(copies, storedObjectLockState)
|
||||
}
|
||||
|
||||
func replaceObjectLockMetadata(metadata map[string]string, state objectLockState) {
|
||||
@@ -534,10 +545,10 @@ func reconcileStoredObjectTags(metadata map[string]string, storedTags, storedTim
|
||||
}
|
||||
}
|
||||
|
||||
// Local tagging mutations must advance the revision they overwrite, even when
|
||||
// Local metadata mutations must advance the revision they overwrite, even when
|
||||
// a request's clock or lock acquisition order is behind the stored revision.
|
||||
// Replica writes use reconcileStoredObjectTags instead of minting a revision.
|
||||
func monotonicTaggingTimestamp(incoming, stored string) string {
|
||||
func monotonicMetadataTimestamp(incoming, stored string) string {
|
||||
requested, err := time.Parse(time.RFC3339Nano, incoming)
|
||||
if err != nil {
|
||||
return incoming
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
// Copyright (c) 2026 Denis Babchuk
|
||||
//
|
||||
// This file is part of MinIO Object Storage stack
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A key below another key ("k/b" under "k") is a directory inside the
|
||||
// directory of that key. The listing treats the directory of an object as a
|
||||
// leaf, so a decommission walk does not see "k/b" while "k" is still in the
|
||||
// pool. The walk has to be repeated once the keys above are gone, or the
|
||||
// check after the bucket finds them and the decommission fails.
|
||||
|
||||
func keysBelowDecommission(t *testing.T, z *erasureServerPools) PoolDecommissionInfo {
|
||||
t.Helper()
|
||||
if err := z.StartDecommission(t.Context(), 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
z.doDecommissionInRoutine(t.Context(), 0)
|
||||
z.poolMetaMutex.RLock()
|
||||
defer z.poolMetaMutex.RUnlock()
|
||||
return *z.poolMeta.Pools[0].Decommission
|
||||
}
|
||||
|
||||
func keysBelowPools(t *testing.T) (*erasureServerPools, string, []string) {
|
||||
t.Helper()
|
||||
z, _ := migrationTestPools(t)
|
||||
const bucket = "keys-below"
|
||||
if err := z.MakeBucket(t.Context(), bucket, MakeBucketOptions{VersioningEnabled: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keys := []string{"k", "k/b", "k/b/c"}
|
||||
for _, key := range keys {
|
||||
putConsistencyObject(t, z, bucket, key, 0, key, ObjectOptions{Versioned: true})
|
||||
}
|
||||
return z, bucket, keys
|
||||
}
|
||||
|
||||
func TestDecommissionMovesKeysBelowAKey(t *testing.T) {
|
||||
z, bucket, keys := keysBelowPools(t)
|
||||
if info := keysBelowDecommission(t, z); !info.Complete || info.Failed {
|
||||
t.Errorf("decommission: complete %v, failed %v; want complete", info.Complete, info.Failed)
|
||||
}
|
||||
for _, key := range keys {
|
||||
if _, err := z.serverPools[0].GetObjectInfo(t.Context(), bucket, key, ObjectOptions{}); !isErrObjectNotFound(err) {
|
||||
t.Errorf("%s still in pool 0: %v", key, err)
|
||||
}
|
||||
if body := migrationTestBody(t, z.GetObjectNInfo, bucket, key, ""); body != key {
|
||||
t.Errorf("%s reads %q", key, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An ILM expiry can remove the parent key during a decommission walk. That
|
||||
// removal must count as progress, or the next key below the parent remains
|
||||
// hidden from the listing and the source pool is left non-empty.
|
||||
func TestDecommissionContinuesAfterLifecycleRemovesParent(t *testing.T) {
|
||||
z, _ := migrationTestPools(t)
|
||||
const bucket = "decommission-ilm-keys-below"
|
||||
if err := z.MakeBucket(t.Context(), bucket, MakeBucketOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
meta, err := globalBucketMetadataSys.Get(bucket)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
meta.LifecycleConfigXML = []byte(`<LifecycleConfiguration><Rule><ID>expire-old</ID><Status>Enabled</Status><Filter><Prefix></Prefix></Filter><Expiration><Days>1</Days></Expiration></Rule></LifecycleConfiguration>`)
|
||||
if err := meta.Save(t.Context(), z); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
globalBucketMetadataSys.Set(bucket, meta)
|
||||
|
||||
putConsistencyObject(t, z, bucket, "k", 0, "parent", ObjectOptions{MTime: UTCNow().Add(-72 * time.Hour)})
|
||||
putConsistencyObject(t, z, bucket, "k/b", 0, "child", ObjectOptions{MTime: UTCNow()})
|
||||
|
||||
if info := keysBelowDecommission(t, z); !info.Complete || info.Failed {
|
||||
t.Fatalf("decommission: complete %v, failed %v; want complete", info.Complete, info.Failed)
|
||||
}
|
||||
if _, err := z.serverPools[0].GetObjectInfo(t.Context(), bucket, "k/b", ObjectOptions{}); !isErrObjectNotFound(err) {
|
||||
t.Fatalf("child still in source pool: %v", err)
|
||||
}
|
||||
if body := migrationTestBody(t, z.GetObjectNInfo, bucket, "k/b", ""); body != "child" {
|
||||
t.Fatalf("child reads %q", body)
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,8 @@ import (
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/dustin/go-humanize"
|
||||
@@ -752,7 +754,34 @@ func (set *erasureObjects) listObjectsToDecommission(ctx context.Context, bi dec
|
||||
return err
|
||||
}
|
||||
|
||||
// decommissionPool moves bucket bi out of pool idx. A key below another key
|
||||
// ("k/b" under "k") is a directory inside the directory of that key, and the
|
||||
// listing does not descend into the directory of an object: a walk sees "k/b"
|
||||
// only once "k" has left the pool. The walk is repeated while it removes
|
||||
// source versions, so each pass moves the keys that the previous one
|
||||
// uncovered. Each repeat needs a version removed from the pool, which takes
|
||||
// no new writes, so the repeats end.
|
||||
func (z *erasureServerPools) decommissionPool(ctx context.Context, idx int, pool *erasureSets, bi decomBucketInfo) error {
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
removed, err := z.decommissionPoolWalk(ctx, idx, pool, bi)
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
if err != nil || removed == 0 {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// decommissionPoolWalk walks bucket bi of pool idx once and returns the number
|
||||
// of snapshot versions verified absent from every online source drive afterwards.
|
||||
func (z *erasureServerPools) decommissionPoolWalk(ctx context.Context, idx int, pool *erasureSets, bi decomBucketInfo) (int64, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
ctx = logger.SetReqInfo(ctx, &logger.ReqInfo{})
|
||||
|
||||
const envDecomWorkers = "_MINIO_DECOMMISSION_WORKERS"
|
||||
@@ -766,9 +795,13 @@ func (z *erasureServerPools) decommissionPool(ctx context.Context, idx int, pool
|
||||
// add that many extra workers.
|
||||
workerSize += len(pool.sets)
|
||||
|
||||
var removed atomic.Int64
|
||||
var verifyErr error
|
||||
var verifyOnce sync.Once
|
||||
|
||||
wk, err := workers.New(workerSize)
|
||||
if err != nil {
|
||||
return err
|
||||
return 0, err
|
||||
}
|
||||
|
||||
var vc *versioning.Versioning
|
||||
@@ -779,24 +812,24 @@ func (z *erasureServerPools) decommissionPool(ctx context.Context, idx int, pool
|
||||
if bi.Name != minioMetaBucket {
|
||||
vc, err = globalBucketVersioningSys.Get(bi.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// Check if the current bucket has a configured lifecycle policy
|
||||
lc, lifecycleUpdatedAt, err = globalBucketMetadataSys.GetLifecycleConfig(bi.Name)
|
||||
if err != nil && !errors.Is(err, BucketLifecycleNotFound{Bucket: bi.Name}) {
|
||||
return err
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// Check if bucket is object locked.
|
||||
lr, err = globalBucketObjectLockSys.Get(bi.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
return 0, err
|
||||
}
|
||||
|
||||
rcfg, err = getReplicationConfig(ctx, bi.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -855,7 +888,7 @@ func (z *erasureServerPools) decommissionPool(ctx context.Context, idx int, pool
|
||||
// to create the appropriate stack.
|
||||
versionsSorter(fivs.Versions).reverse()
|
||||
|
||||
var decommissioned int
|
||||
var candidates []FileInfo
|
||||
var expired []migrationExpiredVersion
|
||||
// The key's history in all pools, read once for its versions.
|
||||
var history migrationHistory
|
||||
@@ -863,8 +896,8 @@ func (z *erasureServerPools) decommissionPool(ctx context.Context, idx int, pool
|
||||
stopFn := globalDecommissionMetrics.log(decomMetricDecommissionObject, idx, bi.Name, version.Name, version.VersionID)
|
||||
// Apply lifecycle rules on the objects that are expired.
|
||||
if action := filterLifecycle(bi.Name, version.Name, version, &history); action.Delete() {
|
||||
decommissioned++
|
||||
if ilmDeletesVersionOnly(action) || (action.DeleteAll() && !version.IsRemote()) {
|
||||
candidates = append(candidates, version)
|
||||
// Run the logical expiry before retiring the source.
|
||||
// A full-object expiry is safe here only for hot-tier
|
||||
// metadata; transitioned objects retain their tier cleanup
|
||||
@@ -912,7 +945,6 @@ func (z *erasureServerPools) decommissionPool(ctx context.Context, idx int, pool
|
||||
// This can happen when rebalance stop races with ongoing rebalance workers.
|
||||
// These rebalance failures can be ignored.
|
||||
if isErrObjectNotFound(err) || isErrVersionNotFound(err) || isDataMovementOverWriteErr(err) {
|
||||
ignore = true
|
||||
stopFn(0, nil)
|
||||
continue
|
||||
}
|
||||
@@ -926,8 +958,7 @@ func (z *erasureServerPools) decommissionPool(ctx context.Context, idx int, pool
|
||||
z.poolMeta.CountItem(idx, 0, failure)
|
||||
z.poolMetaMutex.Unlock()
|
||||
if !failure {
|
||||
// Success keep a count.
|
||||
decommissioned++
|
||||
candidates = append(candidates, version)
|
||||
}
|
||||
auditLogDecom(ctx, "DecomCopyDeleteMarker", bi.Name, version.Name, versionID, err)
|
||||
continue
|
||||
@@ -1013,16 +1044,32 @@ func (z *erasureServerPools) decommissionPool(ctx context.Context, idx int, pool
|
||||
if failure {
|
||||
break // break out on first error
|
||||
}
|
||||
decommissioned++
|
||||
candidates = append(candidates, version)
|
||||
}
|
||||
|
||||
// if all versions were decommissioned, then we can delete the object versions.
|
||||
if decommissioned == len(fivs.Versions) {
|
||||
// Retire only successful moves and eligible expiries. A concurrently
|
||||
// deleted or failed sibling must not strand the successful copies.
|
||||
// Skipped versions are checked for absence, never purged here.
|
||||
{
|
||||
stopFn := globalDecommissionMetrics.log(decomMetricDecommissionRemoveObject, idx, bi.Name, entry.name)
|
||||
err := z.removeMovedVersions(ctx, bi.Name, entry.name, idx, fivs.Versions, expired)
|
||||
err := z.retireMovedVersions(ctx, bi.Name, entry.name, idx, candidates, expired)
|
||||
if err == nil {
|
||||
err = z.removeFreeVersions(ctx, bi.Name, entry.name, idx, fivs.FreeVersions)
|
||||
}
|
||||
// One metadata-only read of each source drive per entry, regardless
|
||||
// of its version count. No read error can become progress or trigger
|
||||
// another walk; leave the bucket queued for an explicit retry.
|
||||
snapshot := slices.Concat(fivs.Versions, fivs.FreeVersions)
|
||||
n, countErr := z.countAbsentMigrationVersions(ctx, bi.Name, entry.name, idx, snapshot)
|
||||
removed.Add(int64(n))
|
||||
// The disposable usage cache is also excluded by the final
|
||||
// verification. Its unreadability still never counts as progress.
|
||||
if countErr != nil && (bi.Name != minioMetaBucket || !strings.Contains(entry.name, dataUsageCacheName)) {
|
||||
verifyOnce.Do(func() { verifyErr = countErr })
|
||||
if err == nil {
|
||||
err = countErr
|
||||
}
|
||||
}
|
||||
stopFn(0, err)
|
||||
auditLogDecom(ctx, "DecomDeleteObject", bi.Name, entry.name, "", err)
|
||||
if err != nil {
|
||||
@@ -1057,12 +1104,19 @@ func (z *erasureServerPools) decommissionPool(ctx context.Context, idx int, pool
|
||||
setN := humanize.Ordinal(setIdx + 1)
|
||||
retryDur := time.Duration(rand.Float64() * float64(5*time.Second))
|
||||
decomLogOnceIf(ctx, fmt.Errorf("listing objects from %s set failed with %v, retrying in %v", setN, err, retryDur), "decom-listing-failed"+setN)
|
||||
time.Sleep(retryDur)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(retryDur):
|
||||
}
|
||||
}
|
||||
}(setIdx)
|
||||
}
|
||||
wk.Wait()
|
||||
return nil
|
||||
if err := ctx.Err(); err != nil {
|
||||
return removed.Load(), err
|
||||
}
|
||||
return removed.Load(), verifyErr
|
||||
}
|
||||
|
||||
//msgp:ignore decomMetrics
|
||||
@@ -1113,6 +1167,9 @@ func (m *decomMetrics) log(d decomMetric, poolIdx int, paths ...string) func(z i
|
||||
// decommission fails and a retry resumes with what is left. A canceled
|
||||
// context returns at once.
|
||||
func (z *erasureServerPools) decommissionInBackground(ctx context.Context, idx int) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
pool := z.serverPools[idx]
|
||||
z.poolMetaMutex.RLock()
|
||||
pending := z.poolMeta.PendingBuckets(idx)
|
||||
@@ -1151,7 +1208,7 @@ func (z *erasureServerPools) decommissionInBackground(ctx context.Context, idx i
|
||||
if err := z.decommissionPool(ctx, idx, pool, bucket); err != nil {
|
||||
stopFn(0, err)
|
||||
if contextCanceled(ctx) {
|
||||
return err
|
||||
return ctx.Err()
|
||||
}
|
||||
failed(bucket, err)
|
||||
continue
|
||||
@@ -1164,7 +1221,7 @@ func (z *erasureServerPools) decommissionInBackground(ctx context.Context, idx i
|
||||
if err := z.checkAfterDecomBucket(ctx, idx, bucket); err != nil {
|
||||
stopFn(0, err)
|
||||
if contextCanceled(ctx) {
|
||||
return err
|
||||
return ctx.Err()
|
||||
}
|
||||
failed(bucket, err)
|
||||
continue
|
||||
@@ -1177,6 +1234,9 @@ func (z *erasureServerPools) decommissionInBackground(ctx context.Context, idx i
|
||||
}
|
||||
z.poolMetaMutex.Unlock()
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
return firstErr
|
||||
}
|
||||
|
||||
@@ -1211,34 +1271,74 @@ func (z *erasureServerPools) removeFreeVersions(ctx context.Context, bucket, obj
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkAfterDecomBucket verifies that a source bucket is physically empty
|
||||
// checkAfterDecomBucket verifies that a source bucket's online drives are empty
|
||||
// before it is removed from the resumable decommission queue. ILM decisions
|
||||
// are deliberately not treated as an exemption: an expired version remains
|
||||
// until the expiry path has performed its logical delete, replication, and
|
||||
// tier cleanup.
|
||||
func (z *erasureServerPools) checkAfterDecomBucket(ctx context.Context, idx int, bi decomBucketInfo) error {
|
||||
versionsFound := 0
|
||||
for _, set := range z.serverPools[idx].sets {
|
||||
if err := set.listObjectsToDecommission(ctx, bi, func(entry metaCacheEntry) {
|
||||
if !entry.isObject() {
|
||||
return
|
||||
}
|
||||
if bi.Name == minioMetaBucket && strings.Contains(entry.name, dataUsageCacheName) {
|
||||
return
|
||||
}
|
||||
fivs, err := entry.fileInfoVersions(bi.Name)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
versionsFound += len(fivs.Versions) + len(fivs.FreeVersions)
|
||||
}); err != nil {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, set := range z.serverPools[idx].sets {
|
||||
disks := set.getDisks()
|
||||
if len(disks) == 0 {
|
||||
return toObjectErr(errErasureReadQuorum, bi.Name)
|
||||
}
|
||||
if versionsFound > 0 {
|
||||
return fmt.Errorf("at least %d object(s)/version(s) were found in bucket `%s` after decommissioning", versionsFound, bi.Name)
|
||||
readQuorum := set.migrationReadQuorum(len(disks))
|
||||
onlineCount := 0
|
||||
for _, disk := range disks {
|
||||
if disk == nil {
|
||||
continue
|
||||
}
|
||||
return nil
|
||||
// Quorum listings omit minority versions, and WalkDir can skip
|
||||
// unreadable metadata. Inspect physical directory entries instead:
|
||||
// even a corrupt or truncated xl.meta must keep this pool queued.
|
||||
pending := []string{bi.Prefix}
|
||||
diskOffline := false
|
||||
for len(pending) > 0 {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
dir := pending[len(pending)-1]
|
||||
pending = pending[:len(pending)-1]
|
||||
entries, err := disk.ListDir(ctx, "", bi.Name, dir, -1)
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
if errors.Is(err, errFileNotFound) || errors.Is(err, errVolumeNotFound) {
|
||||
continue // A concurrent deletion can remove the directory.
|
||||
}
|
||||
if errors.Is(err, errDiskNotFound) {
|
||||
diskOffline = true
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, entry := range entries {
|
||||
name := pathJoin(dir, entry)
|
||||
if bi.Name == minioMetaBucket && strings.Contains(name, dataUsageCacheName) {
|
||||
continue
|
||||
}
|
||||
if entry == xlStorageFormatFile || entry == xlStorageFormatFileV1 {
|
||||
return fmt.Errorf("object metadata %s remains in bucket %s after decommissioning", name, bi.Name)
|
||||
}
|
||||
if strings.HasSuffix(entry, SlashSeparator) {
|
||||
pending = append(pending, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
if diskOffline {
|
||||
continue
|
||||
}
|
||||
onlineCount++
|
||||
}
|
||||
if onlineCount < readQuorum {
|
||||
return toObjectErr(errErasureReadQuorum, bi.Name)
|
||||
}
|
||||
}
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
func (z *erasureServerPools) checkAfterDecom(ctx context.Context, idx int) error {
|
||||
|
||||
@@ -20,6 +20,7 @@ package cmd
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -29,6 +30,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/cespare/xxhash/v2"
|
||||
"github.com/google/uuid"
|
||||
"github.com/minio/madmin-go/v3"
|
||||
"github.com/minio/minio/internal/bucket/lifecycle"
|
||||
@@ -632,6 +634,176 @@ func TestPoolsLifecycleLegacyHistory(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// authoritySignature2021 is the header signature of o, a version whose part
|
||||
// ETags are all empty, as the releases from RELEASE.2021-11-24 until
|
||||
// RELEASE.2022-01-25 computed it: with these ETags.
|
||||
func authoritySignature2021(o xlMetaV2Object) (sig [4]byte) {
|
||||
o.ErasureIndex = 0
|
||||
crc := hashDeterministicString(o.MetaUser) ^ hashDeterministicBytes(o.MetaSys)
|
||||
o.MetaUser, o.MetaSys = nil, nil
|
||||
bts, _ := o.MarshalMsg(nil)
|
||||
crc ^= xxhash.Sum64(bts)
|
||||
binary.LittleEndian.PutUint32(sig[:], uint32(crc^(crc>>32)))
|
||||
return sig
|
||||
}
|
||||
|
||||
// A drive holds the header that the release which wrote a version there
|
||||
// stored. Before RELEASE.2024-04-28 it has no erasure coding, and until
|
||||
// RELEASE.2022-01-25 its signature counted the empty part ETags of versions
|
||||
// written before RELEASE.2021-11-24. A heal writes the header of its own
|
||||
// release and leaves the other drives alone, so the drives of an old version
|
||||
// differ there for good once some were replaced. That is one revision, as
|
||||
// reads and heal take it, on however many of the drives.
|
||||
func TestPoolsLifecycleHeaderEras(t *testing.T) {
|
||||
id := [16]byte{1}
|
||||
current := func(drive int) xlMetaV2ShallowVersion { return authorityRevision(t, id, 1, drive+1, "") }
|
||||
// stored returns the version as drive holds it after change, under the
|
||||
// header that header derives from the one a current release writes.
|
||||
stored := func(drive int, change func(*xlMetaV2Object), header func(xlMetaV2VersionHeader, xlMetaV2Object) xlMetaV2VersionHeader) xlMetaV2ShallowVersion {
|
||||
ver := current(drive)
|
||||
var v xlMetaV2Version
|
||||
if _, err := v.UnmarshalMsg(ver.meta); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
change(v.ObjectV2)
|
||||
meta, err := v.MarshalMsg(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return xlMetaV2ShallowVersion{header: header(ver.header, *v.ObjectV2), meta: meta}
|
||||
}
|
||||
eras := map[string]func(drive int) xlMetaV2ShallowVersion{
|
||||
"no-erasure-coding": func(drive int) xlMetaV2ShallowVersion {
|
||||
return stored(drive, func(*xlMetaV2Object) {}, func(h xlMetaV2VersionHeader, _ xlMetaV2Object) xlMetaV2VersionHeader {
|
||||
h.EcN, h.EcM = 0, 0
|
||||
return h
|
||||
})
|
||||
},
|
||||
"empty-part-etags-signed": func(drive int) xlMetaV2ShallowVersion {
|
||||
return stored(drive, func(o *xlMetaV2Object) { o.PartETags = []string{""} }, func(h xlMetaV2VersionHeader, o xlMetaV2Object) xlMetaV2VersionHeader {
|
||||
h.EcN, h.EcM, h.Signature = 0, 0, authoritySignature2021(o)
|
||||
return h
|
||||
})
|
||||
},
|
||||
}
|
||||
metaVs := slices.Repeat([]uint8{xlMetaVersion}, 16)
|
||||
readQuorum := func(xlMetaV2ShallowVersion, uint8) int { return 12 }
|
||||
for name, old := range eras {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if old(0).header == current(0).header {
|
||||
t.Fatal("fixture: the old header is the current one")
|
||||
}
|
||||
if versionRevision(old(0), xlMetaVersion) != versionRevision(current(7), xlMetaVersion) {
|
||||
t.Error("one version under the headers of two releases has two keys")
|
||||
}
|
||||
// The header of the key orders the merge: it is the current one.
|
||||
if versionRevision(old(0), xlMetaVersion).header != current(0).header {
|
||||
t.Error("the key does not carry the header of the current release")
|
||||
}
|
||||
for healed := range 17 {
|
||||
metas := make([][]xlMetaV2ShallowVersion, 16)
|
||||
for i := range metas {
|
||||
if i < healed {
|
||||
metas[i] = []xlMetaV2ShallowVersion{current(i)}
|
||||
} else {
|
||||
metas[i] = []xlMetaV2ShallowVersion{old(i)}
|
||||
}
|
||||
}
|
||||
merged, err := authoritativeHistory(t.Context(), metas, metaVs, xlMetaVersion, 9, readQuorum)
|
||||
if err != nil || len(merged) != 1 {
|
||||
t.Errorf("%d healed drives: %d versions, err=%v", healed, len(merged), err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
// Control: the erasure coding still tells revisions apart, by the
|
||||
// metadata. Another one under the same header is another revision.
|
||||
other := stored(0, func(o *xlMetaV2Object) { o.ErasureM, o.ErasureN = 8, 8 }, func(h xlMetaV2VersionHeader, _ xlMetaV2Object) xlMetaV2VersionHeader { return h })
|
||||
if versionRevision(other, xlMetaVersion) == versionRevision(current(0), xlMetaVersion) {
|
||||
t.Error("another erasure coding under one header keeps the revision")
|
||||
}
|
||||
}
|
||||
|
||||
// The same on drives, through a heal: two versions with headers from before
|
||||
// RELEASE.2024-04-28 on a set where half of the drives were replaced. GET
|
||||
// reads them and heal has nothing left to do. Their expiry has to run as
|
||||
// well: of a noncurrent version with several pools, and of all versions of a
|
||||
// key with one pool too, which reads the history like the pools do. A
|
||||
// restore takes the drives for one generation, not for a failed status write.
|
||||
func TestPoolsLifecycleHealedOldHeaders(t *testing.T) {
|
||||
for _, single := range []bool{false, true} {
|
||||
name, key := "noncurrent-version", "ncv-old-headers"
|
||||
if single {
|
||||
name, key = "all-versions-one-pool", "eav-old-headers"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
s := newSplitILM(t, versioning.Enabled, splitILMRules)
|
||||
ctx := t.Context()
|
||||
s.write(key, splitAt("old", 5, 0))
|
||||
s.write(key, splitAt("new", 3, 0))
|
||||
ids := []string{s.ids[key+" old"], s.ids[key+" new"]}
|
||||
if single {
|
||||
prior := s.z.serverPools
|
||||
t.Cleanup(func() { s.z.serverPools = prior })
|
||||
s.z.serverPools = s.z.serverPools[:1]
|
||||
}
|
||||
set := s.z.serverPools[0].getHashedSet(key)
|
||||
disks := set.getDisks()
|
||||
for _, disk := range disks {
|
||||
rewriteTierTestMeta(t, disk, s.bucket, key, func(xl *xlMetaV2) {
|
||||
for i := range xl.versions {
|
||||
// Control: the key of a version this release wrote
|
||||
// carries its header as stored.
|
||||
if versionRevision(xl.versions[i], xl.metaV).header != xl.versions[i].header {
|
||||
t.Fatal("the key of a current version carries another header")
|
||||
}
|
||||
xl.versions[i].header.EcN, xl.versions[i].header.EcM = 0, 0
|
||||
}
|
||||
})
|
||||
}
|
||||
replaced := len(disks) / 2
|
||||
for _, disk := range disks[:replaced] {
|
||||
if err := disk.Delete(ctx, s.bucket, key, DeleteOptions{Recursive: true, Immediate: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, id := range ids {
|
||||
if _, err := s.z.HealObject(ctx, s.bucket, key, id, madmin.HealOpts{ScanMode: madmin.HealNormalScan}); err != nil {
|
||||
t.Fatalf("heal %s: %v", id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
for i, disk := range disks {
|
||||
if authorityHeader(t, disk, s.bucket, key, ids[0]).hasEC() != (i < replaced) {
|
||||
t.Fatalf("fixture: drive %d of %d, %d replaced, holds the other header", i, len(disks), replaced)
|
||||
}
|
||||
}
|
||||
authorityPresent(t, s, 0, key, ids[0])
|
||||
res, err := s.z.HealObject(ctx, s.bucket, key, ids[0], madmin.HealOpts{DryRun: true, ScanMode: madmin.HealNormalScan})
|
||||
if err != nil || slices.ContainsFunc(res.Before.Drives, func(d madmin.HealDriveInfo) bool { return d.State != madmin.DriveStateOk }) {
|
||||
t.Fatalf("fixture: heal has work left: %v", err)
|
||||
}
|
||||
|
||||
if gen, err := set.readRestoreGeneration(ctx, s.bucket, key, ids[0]); err != nil || gen.split {
|
||||
t.Errorf("restore generation: split=%v, err=%v", gen.split, err)
|
||||
}
|
||||
if single {
|
||||
_, err = s.z.DeleteObject(ctx, s.bucket, key, authorityDeleteAll)
|
||||
} else {
|
||||
err = authorityExpireVersion(t, s, key, ids[0])
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("expiry: %v", err)
|
||||
}
|
||||
if _, err := s.z.GetObjectInfo(ctx, s.bucket, key, ObjectOptions{VersionID: ids[0]}); !isErrVersionNotFound(err) && !isErrObjectNotFound(err) {
|
||||
t.Errorf("the expired version: %v", err)
|
||||
}
|
||||
if !single {
|
||||
authorityPresent(t, s, 0, key, ids[1])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// authorityDeleteAll is the expiry of all versions of a key as the expiry
|
||||
// worker sends it: a purge of all versions, approved by the lifecycle.
|
||||
var authorityDeleteAll = ObjectOptions{DeleteAllVersions: true, Expiration: ExpirationOptions{Expire: true}}
|
||||
@@ -1088,6 +1260,7 @@ func TestPoolsLifecycleCheckedExpirySafeguard(t *testing.T) {
|
||||
{"hold-equal-stamps", poolLockMetadata("", "ON", old, recent), objectLockState{legalHold: "OFF", legalHoldTimestamp: recent}, false},
|
||||
{"hold-missing-approved-stamp", poolLockMetadata("", "ON", old, old), objectLockState{legalHold: "OFF"}, false},
|
||||
{"retention-newer-locally", poolLockMetadata("GOVERNANCE", "OFF", recent, old), objectLockState{retentionTimestamp: old, legalHold: "OFF", legalHoldTimestamp: old}, false},
|
||||
{"compliance-cannot-release", poolLockMetadata("COMPLIANCE", "OFF", old, old), objectLockState{retentionTimestamp: recent, legalHold: "OFF", legalHoldTimestamp: old}, false},
|
||||
// Controls: the approval strictly supersedes the local protection.
|
||||
{"hold-released", poolLockMetadata("", "ON", old, old), objectLockState{legalHold: "OFF", legalHoldTimestamp: recent}, true},
|
||||
{"retention-released", poolLockMetadata("GOVERNANCE", "OFF", old, old), objectLockState{retentionTimestamp: recent, legalHold: "OFF", legalHoldTimestamp: old}, true},
|
||||
@@ -1494,7 +1667,7 @@ func TestPoolsMigrationLifecycleRefusedExpiryKeepsSource(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
version := FileInfo{Name: key, VersionID: id, ModTime: source.ModTime}
|
||||
err = s.z.removeMovedVersions(ctx, s.bucket, key, 0, []FileInfo{version}, []migrationExpiredVersion{{
|
||||
_, err = s.z.removeMovedVersions(ctx, s.bucket, key, 0, []FileInfo{version}, []migrationExpiredVersion{{
|
||||
version: version,
|
||||
event: lifecycle.Event{Action: lifecycle.DeleteAction},
|
||||
lifecycleUpdatedAt: updatedAt,
|
||||
|
||||
@@ -70,6 +70,8 @@ func TestPoolsLifecycleDuplicateLockMetadata(t *testing.T) {
|
||||
{name: "independent-fields", copies: [2]map[string]string{poolLockMetadata("GOVERNANCE", "OFF", recent, old), poolLockMetadata("", "ON", old, recent)}, mode: "GOVERNANCE", hold: "ON"},
|
||||
{name: "hold-released", copies: [2]map[string]string{poolLockMetadata("", "ON", old, old), poolLockMetadata("", "OFF", old, recent)}, hold: "OFF", delete: true},
|
||||
{name: "retention-released", copies: [2]map[string]string{poolLockMetadata("GOVERNANCE", "OFF", old, old), poolLockMetadata("", "OFF", recent, old)}, hold: "OFF", delete: true},
|
||||
{name: "compliance-before-removal", copies: [2]map[string]string{poolLockMetadata("COMPLIANCE", "OFF", old, old), poolLockMetadata("", "OFF", recent, old)}, mode: "COMPLIANCE", hold: "OFF"},
|
||||
{name: "compliance-after-removal", copies: [2]map[string]string{poolLockMetadata("", "OFF", recent, old), poolLockMetadata("COMPLIANCE", "OFF", old, old)}, mode: "COMPLIANCE", hold: "OFF"},
|
||||
} {
|
||||
for _, batch := range []bool{false, true} {
|
||||
path := "single"
|
||||
|
||||
@@ -187,9 +187,17 @@ type revisionKey struct {
|
||||
// input is canonical: each map, including the part checksums of a legacy
|
||||
// version, is its own section with its entry count and sorted,
|
||||
// length-prefixed entries, since msgp encodes maps in iteration order and an
|
||||
// entry must not move between maps unnoticed. The header of a
|
||||
// legacy version carries a signature over its per-shard checksums, so the
|
||||
// key replaces it with one from the digest.
|
||||
// entry must not move between maps unnoticed.
|
||||
//
|
||||
// A drive holds the header that the release which wrote the version there
|
||||
// stored, and a heal writes the one of its own release: headers from before
|
||||
// RELEASE.2024-04-28 carry no erasure coding, and the signature counted
|
||||
// empty part ETags until RELEASE.2022-01-25. Reads and heal take such drives
|
||||
// for one version (matchesNotStrict), and nothing rewrites them. The key has
|
||||
// both as the current release computes them from the metadata, so that the
|
||||
// header of a version it wrote stays as stored: the signature also orders
|
||||
// versions of one time in the merge. The signature of a legacy version is
|
||||
// over its per-shard checksums; the key has one from the digest.
|
||||
func versionRevision(ver xlMetaV2ShallowVersion, metaV uint8) revisionKey {
|
||||
h := sha256.New()
|
||||
writeUint := func(n int) {
|
||||
@@ -267,8 +275,12 @@ func versionRevision(ver xlMetaV2ShallowVersion, metaV uint8) revisionKey {
|
||||
section("fields", 1)
|
||||
writeField(bts)
|
||||
h.Sum(key.digest[:0])
|
||||
if v.Type == LegacyType {
|
||||
switch v.Type {
|
||||
case LegacyType:
|
||||
copy(key.header.Signature[:], key.digest[:])
|
||||
case ObjectType:
|
||||
key.header.Signature = v.ObjectV2.Signature()
|
||||
key.header.EcN, key.header.EcM = uint8(v.ObjectV2.ErasureN), uint8(v.ObjectV2.ErasureM)
|
||||
}
|
||||
return key
|
||||
}
|
||||
@@ -350,7 +362,7 @@ func authoritativeHistory(ctx context.Context, metas [][]xlMetaV2ShallowVersion,
|
||||
// its count, and one that shares the header could stand in for the
|
||||
// accepted one, so the merge only sees the accepted revision of each
|
||||
// version and free versions as they are. It sees the headers of the
|
||||
// keys, which do not differ between the drives of a legacy version.
|
||||
// keys, which do not differ between the drives of a revision.
|
||||
filtered := make([][]xlMetaV2ShallowVersion, len(metas))
|
||||
for i, versions := range metas {
|
||||
for j, ver := range versions {
|
||||
@@ -575,20 +587,10 @@ func setLifecycleLockState(metadata map[string]string, state objectLockState) {
|
||||
set(ReservedMetadataPrefix+ObjectLockLegalHoldTimestamp, state.legalHoldTimestamp)
|
||||
}
|
||||
|
||||
// newestLockState returns the newest hold and the newest retention of the
|
||||
// copies of a version, which lockRevisionsOrdered found equal or ordered.
|
||||
// newestLockState orders independently revised fields without weakening a
|
||||
// COMPLIANCE retention that any copy still protects.
|
||||
func newestLockState(copies []PoolObjInfo) objectLockState {
|
||||
state := lifecycleLockState(copies[0].ObjInfo.UserDefined)
|
||||
for _, copy := range copies[1:] {
|
||||
next := lifecycleLockState(copy.ObjInfo.UserDefined)
|
||||
if lockRevisionNewer(next.legalHoldTimestamp, state.legalHoldTimestamp) {
|
||||
state.legalHold, state.legalHoldTimestamp = next.legalHold, next.legalHoldTimestamp
|
||||
}
|
||||
if lockRevisionNewer(next.retentionTimestamp, state.retentionTimestamp) {
|
||||
state.mode, state.retainUntil, state.retentionTimestamp = next.mode, next.retainUntil, next.retentionTimestamp
|
||||
}
|
||||
}
|
||||
return state
|
||||
return reducePoolLockState(copies, lifecycleLockState)
|
||||
}
|
||||
|
||||
// checkedExpiryRefused reports whether a set must keep a version whose ILM
|
||||
@@ -611,7 +613,7 @@ func checkedExpiryRefused(lc lifecycle.Lifecycle, lr objectlock.Retention, rcfg
|
||||
if lockRevisionNewer(approved.legalHoldTimestamp, local.legalHoldTimestamp) {
|
||||
obj.UserDefined[strings.ToLower(xhttp.AmzObjectLockLegalHold)] = approved.legalHold
|
||||
}
|
||||
if lockRevisionNewer(approved.retentionTimestamp, local.retentionTimestamp) {
|
||||
if lockRevisionNewer(approved.retentionTimestamp, local.retentionTimestamp) && !local.complianceWouldWeaken(approved) {
|
||||
obj.UserDefined[strings.ToLower(xhttp.AmzObjectLockMode)] = approved.mode
|
||||
obj.UserDefined[strings.ToLower(xhttp.AmzObjectLockRetainUntilDate)] = approved.retainUntil
|
||||
}
|
||||
|
||||
@@ -132,7 +132,7 @@ func TestPoolsMigrationCleanupRechecksSource(t *testing.T) {
|
||||
_, err := z.serverPools[1].DeleteObject(t.Context(), bucket, oi.Name, ObjectOptions{VersionID: nullVersionID})
|
||||
mustIAM(t, err)
|
||||
}
|
||||
err := z.removeMovedVersions(t.Context(), bucket, oi.Name, 0, snapshot, nil)
|
||||
_, err := z.removeMovedVersions(t.Context(), bucket, oi.Name, 0, snapshot, nil)
|
||||
if change != "delete-uuid" && err == nil {
|
||||
t.Fatal("cleanup accepted changed or uncertain state")
|
||||
}
|
||||
|
||||
@@ -414,6 +414,46 @@ func testMigrationLifecycleExpiry(t *testing.T, kind string) {
|
||||
})
|
||||
}
|
||||
|
||||
// Physical expiry advances the walk; creating a delete marker does not remove
|
||||
// the addressed version and must not make a walk repeat without progress.
|
||||
func TestPoolsMigrationLifecycleRemovalProgress(t *testing.T) {
|
||||
for _, versioned := range []bool{false, true} {
|
||||
status := versioning.State("")
|
||||
name, wantRemoved := "physical-delete", 1
|
||||
if versioned {
|
||||
status = versioning.Enabled
|
||||
name, wantRemoved = "delete-marker-only", 0
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
m := newMigrationILM(t, status, false, migrationILMExpireCurrent())
|
||||
oi := m.put("obj-01", "v1", ObjectOptions{Versioned: versioned, MTime: UTCNow().Add(-72 * time.Hour)})
|
||||
_, updatedAt, err := globalBucketMetadataSys.GetLifecycleConfig(m.bucket)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
version := FileInfo{Name: oi.Name, VersionID: oi.VersionID, ModTime: oi.ModTime}
|
||||
removed, err := m.z.removeMovedVersions(t.Context(), m.bucket, oi.Name, 0, []FileInfo{version}, []migrationExpiredVersion{{
|
||||
version: version,
|
||||
event: lifecycle.Event{Action: lifecycle.DeleteAction},
|
||||
lifecycleUpdatedAt: updatedAt,
|
||||
source: lcEventSrc_Decom,
|
||||
}})
|
||||
if err != nil || removed != wantRemoved {
|
||||
t.Fatalf("removed %d versions, err %v; want %d, nil", removed, err, wantRemoved)
|
||||
}
|
||||
if versioned {
|
||||
if body := migrationTestBody(t, m.z.GetObjectNInfo, m.bucket, oi.Name, oi.VersionID); body != "obj-01 v1" {
|
||||
t.Fatalf("noncurrent version reads %q", body)
|
||||
}
|
||||
}
|
||||
current, err := m.z.GetObjectInfo(t.Context(), m.bucket, oi.Name, ObjectOptions{})
|
||||
if !isErrObjectNotFound(err) || current.DeleteMarker != versioned {
|
||||
t.Fatalf("after expiry: delete marker %t, err %v", current.DeleteMarker, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A lifecycle action found during the migration walk must not be applied
|
||||
// after the bucket's lifecycle configuration changes. The source copy stays
|
||||
// available for a fresh scan under the new policy.
|
||||
|
||||
@@ -0,0 +1,601 @@
|
||||
// Copyright (c) 2026 Feng Ruohang
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
//
|
||||
// This file is part of Silo Object Storage stack
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"slices"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/minio/minio/internal/bucket/lifecycle"
|
||||
)
|
||||
|
||||
// The walk has already captured the version when its data read reaches this
|
||||
// hook. Metadata-only checks made by a concurrent DELETE do not reenter it.
|
||||
type migrationProgressReadDisk struct {
|
||||
StorageAPI
|
||||
bucket, object, version string
|
||||
before func() error
|
||||
}
|
||||
|
||||
func (d migrationProgressReadDisk) ReadVersion(ctx context.Context, orig, bucket, object, version string, opts ReadOptions) (FileInfo, error) {
|
||||
if opts.ReadData && bucket == d.bucket && object == d.object && version == d.version {
|
||||
if err := d.before(); err != nil {
|
||||
return FileInfo{}, err
|
||||
}
|
||||
}
|
||||
return d.StorageAPI.ReadVersion(ctx, orig, bucket, object, version, opts)
|
||||
}
|
||||
|
||||
// Buffer every drive's first walk before publishing any entry. Thus deleting
|
||||
// the parent cannot accidentally let this same walk discover the child.
|
||||
type migrationSnapshotWalk struct {
|
||||
bucket string
|
||||
drives int32
|
||||
calls atomic.Int32
|
||||
ready chan struct{}
|
||||
}
|
||||
|
||||
type migrationSnapshotWalkDisk struct {
|
||||
StorageAPI
|
||||
snapshot *migrationSnapshotWalk
|
||||
}
|
||||
|
||||
func (d migrationSnapshotWalkDisk) WalkDir(ctx context.Context, opts WalkDirOptions, out io.Writer) error {
|
||||
if opts.Bucket != d.snapshot.bucket {
|
||||
return d.StorageAPI.WalkDir(ctx, opts, out)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := d.StorageAPI.WalkDir(ctx, opts, &buf); err != nil {
|
||||
return err
|
||||
}
|
||||
if d.snapshot.calls.Add(1) == d.snapshot.drives {
|
||||
close(d.snapshot.ready)
|
||||
}
|
||||
select {
|
||||
case <-d.snapshot.ready:
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
_, err := io.Copy(out, &buf)
|
||||
return err
|
||||
}
|
||||
|
||||
func migrationProgressSnapshot(t *testing.T, z *erasureServerPools, bucket, key string) *migrationSnapshotWalk {
|
||||
t.Helper()
|
||||
s := &migrationSnapshotWalk{bucket: bucket, drives: int32(len(z.serverPools[0].getHashedSet(key).getDisks())), ready: make(chan struct{})}
|
||||
t.Cleanup(conditionalPutSwapDisks(z.serverPools[0], key, func(d StorageAPI) StorageAPI {
|
||||
return migrationSnapshotWalkDisk{StorageAPI: d, snapshot: s}
|
||||
}))
|
||||
return s
|
||||
}
|
||||
|
||||
func migrationProgressPrepare(t *testing.T, z *erasureServerPools, kind string) {
|
||||
t.Helper()
|
||||
migrationTestMover(t, z, 0, kind)
|
||||
z.poolMetaMutex.Lock()
|
||||
z.poolMeta.Pools[0].LastUpdate = UTCNow()
|
||||
z.poolMetaMutex.Unlock()
|
||||
}
|
||||
|
||||
func TestDecommissionSplitMetadataDoesNotAdvance(t *testing.T) {
|
||||
m := newMigrationILM(t, "", false, migrationILMExpireCurrent())
|
||||
const key = "obj-01"
|
||||
m.put(key, "old", ObjectOptions{MTime: UTCNow().Add(-96 * time.Hour)})
|
||||
disks := m.z.serverPools[0].getHashedSet(key).getDisks()
|
||||
old := make([][]byte, len(disks)/2)
|
||||
for i := range old {
|
||||
var err error
|
||||
old[i], err = disks[i].ReadAll(t.Context(), m.bucket, pathJoin(key, xlStorageFormatFile))
|
||||
mustIAM(t, err)
|
||||
}
|
||||
m.put(key, "new-and-longer", ObjectOptions{MTime: UTCNow().Add(-72 * time.Hour)})
|
||||
for i := range old {
|
||||
mustIAM(t, disks[i].WriteAll(t.Context(), m.bucket, pathJoin(key, xlStorageFormatFile), old[i]))
|
||||
}
|
||||
if _, err := m.z.serverPools[0].GetObjectInfo(t.Context(), m.bucket, key, ObjectOptions{VersionID: nullVersionID}); !isErrVersionNotFound(err) {
|
||||
t.Fatalf("fixture must expose the ambiguous logical NotFound, got %v", err)
|
||||
}
|
||||
migrationProgressPrepare(t, m.z, "decommission")
|
||||
walk := migrationProgressSnapshot(t, m.z, m.bucket, key)
|
||||
bi := decomBucketInfo{Name: m.bucket}
|
||||
// Check a single pass first, so the unfixed implementation fails rather
|
||||
// than looping until the test timeout.
|
||||
removed, err := m.z.decommissionPoolWalk(t.Context(), 0, m.z.serverPools[0], bi)
|
||||
if err != nil || removed != 0 {
|
||||
t.Fatalf("split metadata reported progress: removed=%d err=%v", removed, err)
|
||||
}
|
||||
mustIAM(t, m.z.decommissionPool(t.Context(), 0, m.z.serverPools[0], bi))
|
||||
if got := walk.calls.Load(); got != 2*walk.drives {
|
||||
t.Fatalf("unchanged metadata repeated the walk: calls=%d", got)
|
||||
}
|
||||
if err := m.z.checkAfterDecomBucket(t.Context(), 0, bi); err == nil {
|
||||
t.Fatal("final verification accepted split metadata still on disk")
|
||||
}
|
||||
for _, disk := range disks {
|
||||
if _, err := disk.ReadVersion(t.Context(), "", m.bucket, key, nullVersionID, ReadOptions{}); err != nil {
|
||||
t.Fatalf("migration removed an ambiguous source revision: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolsMigrationConcurrentDeleteCleanup(t *testing.T) {
|
||||
for _, kind := range []string{"decommission", "rebalance"} {
|
||||
t.Run(kind, func(t *testing.T) {
|
||||
z, bucket := migrationTestPools(t)
|
||||
v1 := putConsistencyObject(t, z, bucket, "k", 0, "older", ObjectOptions{Versioned: true, MTime: UTCNow().Add(-time.Hour)})
|
||||
v2 := putConsistencyObject(t, z, bucket, "k", 0, "newer", ObjectOptions{Versioned: true})
|
||||
var once sync.Once
|
||||
var deleteErr error
|
||||
before := func() error {
|
||||
once.Do(func() {
|
||||
_, deleteErr = z.DeleteObject(t.Context(), bucket, "k", ObjectOptions{Versioned: true, VersionID: v2.VersionID})
|
||||
})
|
||||
return deleteErr
|
||||
}
|
||||
restore := conditionalPutSwapDisks(z.serverPools[0], "k", func(d StorageAPI) StorageAPI {
|
||||
return migrationProgressReadDisk{StorageAPI: d, bucket: bucket, object: "k", version: v2.VersionID, before: before}
|
||||
})
|
||||
defer restore()
|
||||
if failed := migrationTestWalk(t, z, bucket, 0, kind); failed != 0 {
|
||||
t.Fatalf("failed moves: %d", failed)
|
||||
}
|
||||
restore()
|
||||
for _, v := range []ObjectInfo{v1, v2} {
|
||||
for _, disk := range z.serverPools[0].getHashedSet("k").getDisks() {
|
||||
_, err := disk.ReadVersion(t.Context(), "", bucket, "k", v.VersionID, ReadOptions{})
|
||||
if !errors.Is(err, errFileVersionNotFound) && !errors.Is(err, errFileNotFound) {
|
||||
t.Fatalf("source version %s remained: %v", v.VersionID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if body := migrationTestBody(t, z.GetObjectNInfo, bucket, "k", v1.VersionID); body != "older" {
|
||||
t.Fatalf("moved version reads %q", body)
|
||||
}
|
||||
if _, err := z.GetObjectInfo(t.Context(), bucket, "k", ObjectOptions{VersionID: v2.VersionID}); !isErrVersionNotFound(err) {
|
||||
t.Fatalf("concurrently deleted version resurrected: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolsMigrationSiblingFailureCleanup(t *testing.T) {
|
||||
for _, kind := range []string{"decommission", "rebalance"} {
|
||||
for _, loseDestination := range []bool{false, true} {
|
||||
name := kind + "/destination-retained"
|
||||
if loseDestination {
|
||||
name = kind + "/destination-removed"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
z, bucket := migrationTestPools(t)
|
||||
v1 := putConsistencyObject(t, z, bucket, "k", 0, "older", ObjectOptions{Versioned: true, MTime: UTCNow().Add(-time.Hour)})
|
||||
v2 := putConsistencyObject(t, z, bucket, "k", 0, "newer", ObjectOptions{Versioned: true})
|
||||
var once sync.Once
|
||||
var deleteErr error
|
||||
before := func() error {
|
||||
once.Do(func() {
|
||||
if loseDestination {
|
||||
_, deleteErr = z.serverPools[1].DeleteObject(t.Context(), bucket, "k", ObjectOptions{VersionID: v1.VersionID})
|
||||
}
|
||||
})
|
||||
return errDiskNotFound // only the sibling's data read fails
|
||||
}
|
||||
restore := conditionalPutSwapDisks(z.serverPools[0], "k", func(d StorageAPI) StorageAPI {
|
||||
return migrationProgressReadDisk{StorageAPI: d, bucket: bucket, object: "k", version: v2.VersionID, before: before}
|
||||
})
|
||||
defer restore()
|
||||
migrationProgressPrepare(t, z, kind)
|
||||
if kind == "rebalance" {
|
||||
mustIAM(t, z.rebalanceBucket(t.Context(), bucket, 0))
|
||||
} else {
|
||||
_, err := z.decommissionPoolWalk(t.Context(), 0, z.serverPools[0], decomBucketInfo{Name: bucket})
|
||||
mustIAM(t, err)
|
||||
if err := z.checkAfterDecomBucket(t.Context(), 0, decomBucketInfo{Name: bucket}); err == nil {
|
||||
t.Fatal("failed sibling must block final decommission verification")
|
||||
}
|
||||
}
|
||||
restore()
|
||||
mustIAM(t, deleteErr)
|
||||
if body := migrationTestBody(t, z.GetObjectNInfo, bucket, "k", v2.VersionID); body != "newer" {
|
||||
t.Fatalf("failed sibling reads %q", body)
|
||||
}
|
||||
if body := migrationTestBody(t, z.GetObjectNInfo, bucket, "k", v1.VersionID); body != "older" {
|
||||
t.Fatalf("successful candidate lost: %q", body)
|
||||
}
|
||||
_, err := z.serverPools[0].GetObjectInfo(t.Context(), bucket, "k", ObjectOptions{VersionID: v1.VersionID})
|
||||
if loseDestination && err != nil || !loseDestination && !isErrVersionNotFound(err) {
|
||||
t.Fatalf("source cleanup ignored destination state: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecommissionConcurrentParentDeleteRescansChildren(t *testing.T) {
|
||||
z, bucket := migrationTestPools(t)
|
||||
putConsistencyObject(t, z, bucket, "k", 0, "parent", ObjectOptions{})
|
||||
putConsistencyObject(t, z, bucket, "k/b", 0, "child", ObjectOptions{})
|
||||
migrationProgressSnapshot(t, z, bucket, "k")
|
||||
var once sync.Once
|
||||
var deleteErr error
|
||||
t.Cleanup(conditionalPutSwapDisks(z.serverPools[0], "k", func(d StorageAPI) StorageAPI {
|
||||
return migrationProgressReadDisk{StorageAPI: d, bucket: bucket, object: "k", version: nullVersionID, before: func() error {
|
||||
once.Do(func() {
|
||||
_, deleteErr = z.DeleteObject(t.Context(), bucket, "k", ObjectOptions{VersionID: nullVersionID})
|
||||
})
|
||||
return deleteErr
|
||||
}}
|
||||
}))
|
||||
if failed := migrationTestWalk(t, z, bucket, 0, "decommission"); failed != 0 {
|
||||
t.Fatalf("failed moves: %d", failed)
|
||||
}
|
||||
mustIAM(t, z.checkAfterDecomBucket(t.Context(), 0, decomBucketInfo{Name: bucket}))
|
||||
if body := migrationTestBody(t, z.GetObjectNInfo, bucket, "k/b", ""); body != "child" {
|
||||
t.Fatalf("child reads %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecommissionDegradedPoolRescansChildren(t *testing.T) {
|
||||
z, bucket := migrationTestPools(t)
|
||||
putConsistencyObject(t, z, bucket, "k", 0, "parent", ObjectOptions{})
|
||||
putConsistencyObject(t, z, bucket, "k/b", 0, "child", ObjectOptions{})
|
||||
var wrapped int
|
||||
t.Cleanup(conditionalPutSwapDisks(z.serverPools[0], "k", func(d StorageAPI) StorageAPI {
|
||||
wrapped++
|
||||
if wrapped == 1 {
|
||||
return nil
|
||||
}
|
||||
return d
|
||||
}))
|
||||
if failed := migrationTestWalk(t, z, bucket, 0, "decommission"); failed != 0 {
|
||||
t.Fatalf("failed moves: %d", failed)
|
||||
}
|
||||
mustIAM(t, z.checkAfterDecomBucket(t.Context(), 0, decomBucketInfo{Name: bucket}))
|
||||
if body := migrationTestBody(t, z.GetObjectNInfo, bucket, "k/b", ""); body != "child" {
|
||||
t.Fatalf("child reads %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFreeVersionDecommissionRescansChildren(t *testing.T) {
|
||||
f := newFreeVersionTest(t, "")
|
||||
f.bucket = "free-parent"
|
||||
mustIAM(t, f.z.MakeBucket(t.Context(), f.bucket, MakeBucketOptions{VersioningEnabled: true}))
|
||||
f.deleteVersion(f.tiered("k", "parent", UTCNow().Add(-time.Hour)))
|
||||
f.put("k/b", "child", UTCNow())
|
||||
migrationProgressSnapshot(t, f.z, f.bucket, "k")
|
||||
migrationProgressPrepare(t, f.z, "decommission")
|
||||
bi := decomBucketInfo{Name: f.bucket}
|
||||
removed, err := f.z.decommissionPoolWalk(t.Context(), 0, f.z.serverPools[0], bi)
|
||||
if err != nil || removed != 1 {
|
||||
t.Fatalf("free-only parent removal: removed=%d err=%v", removed, err)
|
||||
}
|
||||
if err := f.z.checkAfterDecomBucket(t.Context(), 0, bi); err == nil {
|
||||
t.Fatal("the newly exposed child must still block completion")
|
||||
}
|
||||
mustIAM(t, f.z.decommissionPool(t.Context(), 0, f.z.serverPools[0], bi))
|
||||
mustIAM(t, f.z.checkAfterDecomBucket(t.Context(), 0, bi))
|
||||
if body := migrationTestBody(t, f.z.GetObjectNInfo, f.bucket, "k/b", ""); body != "k/b child" {
|
||||
t.Fatalf("child reads %q", body)
|
||||
}
|
||||
if !slices.Contains(f.tier.removed, f.remote("k", "parent")) {
|
||||
t.Fatal("free version cleanup did not delete its remote object")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolsMigrationCancellation(t *testing.T) {
|
||||
for _, kind := range []string{"decommission", "rebalance"} {
|
||||
for _, phase := range []string{"before-walk", "during-read", "deadline"} {
|
||||
name := kind + "/" + phase
|
||||
t.Run(name, func(t *testing.T) {
|
||||
z, bucket := migrationTestPools(t)
|
||||
oi := putConsistencyObject(t, z, bucket, "k", 0, "source", ObjectOptions{Versioned: true})
|
||||
migrationProgressPrepare(t, z, kind)
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
wantErr := context.Canceled
|
||||
if phase == "deadline" {
|
||||
cancel()
|
||||
ctx, cancel = context.WithDeadline(t.Context(), time.Time{})
|
||||
wantErr = context.DeadlineExceeded
|
||||
}
|
||||
defer cancel()
|
||||
if phase == "during-read" {
|
||||
t.Cleanup(conditionalPutSwapDisks(z.serverPools[0], "k", func(d StorageAPI) StorageAPI {
|
||||
return migrationProgressReadDisk{StorageAPI: d, bucket: bucket, object: "k", version: oi.VersionID, before: func() error { cancel(); return ctx.Err() }}
|
||||
}))
|
||||
} else {
|
||||
cancel()
|
||||
}
|
||||
var err error
|
||||
if kind == "decommission" {
|
||||
err = z.decommissionPool(ctx, 0, z.serverPools[0], decomBucketInfo{Name: bucket})
|
||||
} else {
|
||||
err = z.rebalanceBucket(ctx, bucket, 0)
|
||||
}
|
||||
if !errors.Is(err, wantErr) {
|
||||
t.Fatalf("cancellation returned %v", err)
|
||||
}
|
||||
if err := z.decommissionInBackground(ctx, 0); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("canceled background migration returned %v", err)
|
||||
}
|
||||
if err := z.rebalanceBuckets(ctx, 0); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("canceled background rebalance returned %v", err)
|
||||
}
|
||||
if n, err := z.countAbsentMigrationVersions(ctx, bucket, "k", 0, []FileInfo{{VersionID: oi.VersionID}}); n != 0 || !errors.Is(err, wantErr) {
|
||||
t.Fatalf("canceled progress verification: removed=%d err=%v", n, err)
|
||||
}
|
||||
if _, err := z.serverPools[0].GetObjectInfo(t.Context(), bucket, "k", ObjectOptions{VersionID: oi.VersionID}); err != nil {
|
||||
t.Fatalf("canceled migration lost source: %v", err)
|
||||
}
|
||||
if err := z.checkAfterDecomBucket(ctx, 0, decomBucketInfo{Name: bucket}); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("canceled verification returned %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type migrationProgressRawDisk struct {
|
||||
StorageAPI
|
||||
buf []byte
|
||||
err error
|
||||
reads *atomic.Int32
|
||||
}
|
||||
|
||||
func (d migrationProgressRawDisk) ReadXL(ctx context.Context, bucket, object string, data bool) (RawFileInfo, error) {
|
||||
d.reads.Add(1)
|
||||
if data {
|
||||
return RawFileInfo{}, errors.New("progress check requested object data")
|
||||
}
|
||||
return RawFileInfo{Buf: d.buf}, d.err
|
||||
}
|
||||
|
||||
func (d migrationProgressRawDisk) ListDir(context.Context, string, string, string, int) ([]string, error) {
|
||||
return nil, errFileNotFound
|
||||
}
|
||||
|
||||
func TestPoolsMigrationPhysicalProgress(t *testing.T) {
|
||||
f := newFreeVersionTest(t, "")
|
||||
buf := f.xlMeta(0, "obj-01")
|
||||
versions, err := getFileInfoVersions(buf, f.bucket, "obj-01", true)
|
||||
mustIAM(t, err)
|
||||
if len(versions.Versions) != 3 || !versions.Versions[0].TierFreeVersion() {
|
||||
t.Fatal("fixture must contain two live versions and one free version")
|
||||
}
|
||||
missing := FileInfo{VersionID: mustGetUUID()}
|
||||
snapshot := append(slices.Clone(versions.Versions), missing)
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
buf []byte
|
||||
err error
|
||||
want int
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "all-absent", err: errFileNotFound, want: 4},
|
||||
{name: "missing-volume", err: errVolumeNotFound, want: 4},
|
||||
{name: "minority-live-and-free", buf: buf, want: 1},
|
||||
{name: "unavailable", err: errDiskNotFound, wantErr: true},
|
||||
{name: "read-quorum", err: errErasureReadQuorum, wantErr: true},
|
||||
{name: "corrupt-read", err: errFileCorrupt, wantErr: true},
|
||||
{name: "corrupt-metadata", buf: []byte("broken xl.meta"), wantErr: true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var reads atomic.Int32
|
||||
disks := make([]StorageAPI, 4)
|
||||
for i := range disks {
|
||||
disks[i] = migrationProgressRawDisk{err: errFileNotFound, reads: &reads}
|
||||
}
|
||||
disks[3] = migrationProgressRawDisk{buf: tc.buf, err: tc.err, reads: &reads}
|
||||
er := erasureObjects{getDisks: func() []StorageAPI { return disks }}
|
||||
n, err := er.countAbsentMigrationVersions(t.Context(), f.bucket, "obj-01", snapshot)
|
||||
if n != tc.want || (err != nil) != tc.wantErr {
|
||||
t.Fatalf("removed=%d err=%v, want %d / error=%t", n, err, tc.want, tc.wantErr)
|
||||
}
|
||||
if reads.Load() != int32(len(disks)) {
|
||||
t.Fatalf("read count %d must not depend on version count", reads.Load())
|
||||
}
|
||||
})
|
||||
}
|
||||
putConsistencyObject(t, f.z, f.bucket, "null", 0, "null-data", ObjectOptions{})
|
||||
nullBuf := f.xlMeta(0, "null")
|
||||
var reads atomic.Int32
|
||||
er := erasureObjects{getDisks: func() []StorageAPI {
|
||||
return []StorageAPI{migrationProgressRawDisk{buf: nullBuf, reads: &reads}}
|
||||
}}
|
||||
if n, err := er.countAbsentMigrationVersions(t.Context(), f.bucket, "null", []FileInfo{{VersionID: nullVersionID}, {VersionID: ""}}); n != 0 || err != nil {
|
||||
t.Fatalf("null version normalization: removed=%d err=%v", n, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCountAbsentMigrationVersionsToleratesConfiguredOfflineDrives(t *testing.T) {
|
||||
makeDisks := func(offlineCount int, asError bool) []StorageAPI {
|
||||
disks := make([]StorageAPI, 4)
|
||||
for i := range disks {
|
||||
disks[i] = migrationProgressRawDisk{err: errFileNotFound, reads: &atomic.Int32{}}
|
||||
}
|
||||
for i := range offlineCount {
|
||||
if asError {
|
||||
disks[i] = migrationProgressRawDisk{err: errDiskNotFound, reads: &atomic.Int32{}}
|
||||
} else {
|
||||
disks[i] = nil
|
||||
}
|
||||
}
|
||||
return disks
|
||||
}
|
||||
snapshot := []FileInfo{{VersionID: mustGetUUID()}}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
offline int
|
||||
parity int
|
||||
asError bool
|
||||
wantCount int
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "nil-drive-within-parity", offline: 1, parity: 1, wantCount: 1},
|
||||
{name: "read-error-within-parity", offline: 1, parity: 1, asError: true, wantCount: 1},
|
||||
{name: "half-offline-within-parity", offline: 2, parity: 2, wantCount: 1},
|
||||
{name: "offline-beyond-parity", offline: 2, parity: 1, wantErr: true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
disks := makeDisks(tc.offline, tc.asError)
|
||||
er := erasureObjects{
|
||||
setDriveCount: len(disks),
|
||||
defaultParityCount: tc.parity,
|
||||
getDisks: func() []StorageAPI { return disks },
|
||||
}
|
||||
got, err := er.countAbsentMigrationVersions(t.Context(), "bucket", "object", snapshot)
|
||||
if got != tc.wantCount || (err != nil) != tc.wantErr {
|
||||
t.Fatalf("removed=%d err=%v, want %d / error=%t", got, err, tc.wantCount, tc.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A successful quorum deletion can leave one source drive behind. It must not
|
||||
// count as physical removal or pass the final check, nor cause a retry loop.
|
||||
func TestDecommissionQuorumDeletionIsNotPhysicalProgress(t *testing.T) {
|
||||
z, bucket := migrationTestPools(t)
|
||||
oi := putConsistencyObject(t, z, bucket, "k", 0, "payload", ObjectOptions{Versioned: true})
|
||||
move := migrationTestMover(t, z, 0, "decommission")
|
||||
mustIAM(t, move(t.Context(), 0, bucket, migrationTestReader(t, z, 0, oi)))
|
||||
first := true
|
||||
restore := conditionalPutSwapDisks(z.serverPools[0], "k", func(d StorageAPI) StorageAPI {
|
||||
if first {
|
||||
first = false
|
||||
return consistencyDeleteFaultDisk{StorageAPI: d, bucket: bucket, object: "k", version: oi.VersionID}
|
||||
}
|
||||
return d
|
||||
})
|
||||
defer restore()
|
||||
snapshot := []FileInfo{{VersionID: oi.VersionID, ModTime: oi.ModTime}}
|
||||
for range 2 {
|
||||
n, err := z.removeMovedVersions(t.Context(), bucket, "k", 0, snapshot, nil)
|
||||
if err != nil || n != 0 {
|
||||
t.Fatalf("partial deletion reported progress: removed=%d err=%v", n, err)
|
||||
}
|
||||
}
|
||||
if err := z.checkAfterDecomBucket(t.Context(), 0, decomBucketInfo{Name: bucket}); err == nil {
|
||||
t.Fatal("final verification accepted a minority-drive source copy")
|
||||
}
|
||||
if body := migrationTestBody(t, z.GetObjectNInfo, bucket, "k", oi.VersionID); body != "payload" {
|
||||
t.Fatalf("destination copy lost: %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecommissionProgressReadFailureStopsWalk(t *testing.T) {
|
||||
z, bucket := migrationTestPools(t)
|
||||
putConsistencyObject(t, z, bucket, "k", 0, "payload", ObjectOptions{Versioned: true})
|
||||
migrationProgressPrepare(t, z, "decommission")
|
||||
walk := migrationProgressSnapshot(t, z, bucket, "k")
|
||||
restore := conditionalPutSwapDisks(z.serverPools[0], "k", func(d StorageAPI) StorageAPI {
|
||||
return tierReadFaultDisk{StorageAPI: d, bucket: bucket, object: "k", err: errDiskNotFound}
|
||||
})
|
||||
defer restore()
|
||||
if err := z.decommissionPool(t.Context(), 0, z.serverPools[0], decomBucketInfo{Name: bucket}); err == nil {
|
||||
t.Fatal("unreadable source progress check reported success")
|
||||
}
|
||||
if got := walk.calls.Load(); got != walk.drives {
|
||||
t.Fatalf("verification failure repeated the walk: calls=%d", got)
|
||||
}
|
||||
restore()
|
||||
if body := migrationTestBody(t, z.GetObjectNInfo, bucket, "k", ""); body != "payload" {
|
||||
t.Fatalf("quorum move lost destination: %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
type migrationProgressListFaultDisk struct {
|
||||
StorageAPI
|
||||
}
|
||||
|
||||
func (d migrationProgressListFaultDisk) ListDir(context.Context, string, string, string, int) ([]string, error) {
|
||||
return nil, errDiskAccessDenied
|
||||
}
|
||||
|
||||
func TestDecommissionFinalVerificationFailsClosed(t *testing.T) {
|
||||
for _, state := range []string{"offline-drive", "directory-error", "corrupt-metadata", "truncated-metadata", "legacy-metadata"} {
|
||||
t.Run(state, func(t *testing.T) {
|
||||
z, bucket := migrationTestPools(t)
|
||||
switch state {
|
||||
case "offline-drive":
|
||||
set := z.serverPools[0].getHashedSet("k")
|
||||
maxOffline := set.defaultParityCount + 1
|
||||
wrapped := 0
|
||||
t.Cleanup(conditionalPutSwapDisks(z.serverPools[0], "k", func(d StorageAPI) StorageAPI {
|
||||
wrapped++
|
||||
if wrapped <= maxOffline {
|
||||
return nil
|
||||
}
|
||||
return d
|
||||
}))
|
||||
case "directory-error":
|
||||
t.Cleanup(conditionalPutSwapDisks(z.serverPools[0], "k", func(d StorageAPI) StorageAPI {
|
||||
return migrationProgressListFaultDisk{StorageAPI: d}
|
||||
}))
|
||||
default:
|
||||
// Real metadata on only one drive, without a readable quorum.
|
||||
// This also covers errors that WalkDir deliberately skips.
|
||||
disk := z.serverPools[0].getHashedSet("k").getDisks()[0]
|
||||
file, data := xlStorageFormatFile, []byte("broken xl.meta")
|
||||
if state == "truncated-metadata" {
|
||||
data = nil
|
||||
}
|
||||
if state == "legacy-metadata" {
|
||||
file = xlStorageFormatFileV1
|
||||
}
|
||||
mustIAM(t, disk.WriteAll(t.Context(), bucket, pathJoin("k", file), data))
|
||||
}
|
||||
if err := z.checkAfterDecomBucket(t.Context(), 0, decomBucketInfo{Name: bucket}); err == nil {
|
||||
t.Fatal("unreadable storage was accepted as physically empty")
|
||||
}
|
||||
if n, err := z.countAbsentMigrationVersions(t.Context(), bucket, "k", 0, []FileInfo{{VersionID: mustGetUUID()}}); n != 0 || err == nil {
|
||||
t.Fatalf("unreadable storage was counted as progress: removed=%d err=%v", n, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFreeVersionDecommissionPreservesLiveRemoteOwner(t *testing.T) {
|
||||
f := newFreeVersionTest(t, "")
|
||||
const key = "obj-02"
|
||||
fv, tuple := f.freeRecord(0, key)
|
||||
owner := putConsistencyObject(t, f.z, f.bucket, key, 1, "live-owner", ObjectOptions{Versioned: true})
|
||||
infos, errs := readAllFileInfo(t.Context(), f.z.serverPools[1].getHashedSet(key).getDisks(), "", f.bucket, key, owner.VersionID, false, false)
|
||||
mustIAM(t, errs[0])
|
||||
fi := infos[0]
|
||||
fi.TransitionStatus, fi.TransitionTier = lifecycle.TransitionComplete, tuple.Tier
|
||||
fi.TransitionedObjName, fi.TransitionVersionID = tuple.Object, tuple.VersionID
|
||||
mustIAM(t, f.z.serverPools[1].DecomTieredObject(t.Context(), f.bucket, key, fi, ObjectOptions{}))
|
||||
if err := f.z.removeFreeVersions(t.Context(), f.bucket, key, 0, []FileInfo{fv}); !errors.Is(err, errTierStillReferenced) {
|
||||
t.Fatalf("cleanup with a live remote owner: %v", err)
|
||||
}
|
||||
if n, err := f.z.countAbsentMigrationVersions(t.Context(), f.bucket, key, 0, []FileInfo{fv}); err != nil || n != 0 {
|
||||
t.Fatalf("retained free record reported progress: removed=%d err=%v", n, err)
|
||||
}
|
||||
if slices.Contains(f.tier.removed, tuple.Object) {
|
||||
t.Fatal("cleanup deleted a live remote object")
|
||||
}
|
||||
if err := f.z.checkAfterDecomBucket(t.Context(), 0, decomBucketInfo{Name: f.bucket}); err == nil {
|
||||
t.Fatal("retained free record must block final decommission verification")
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,147 @@
|
||||
|
||||
package cmd
|
||||
|
||||
import xhttp "github.com/minio/minio/internal/http"
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
xhttp "github.com/minio/minio/internal/http"
|
||||
"github.com/pgsty/silo-pkg/v3/sync/errgroup"
|
||||
)
|
||||
|
||||
func migrationVersionKey(versionID string) string {
|
||||
if versionID == "" || versionID == nullVersionID {
|
||||
return nullVersionID
|
||||
}
|
||||
return versionID
|
||||
}
|
||||
|
||||
// migrationReadQuorum requires enough online drives to intersect every
|
||||
// committed write quorum. All of those drives must be checked: a minority
|
||||
// remnant still prevents progress. Unknown set geometry tolerates no outages.
|
||||
func (er erasureObjects) migrationReadQuorum(diskCount int) int {
|
||||
if er.setDriveCount <= 0 || er.setDriveCount != diskCount || er.defaultParityCount < 0 || er.defaultParityCount > er.setDriveCount/2 {
|
||||
return diskCount
|
||||
}
|
||||
return er.defaultRQuorum()
|
||||
}
|
||||
|
||||
// countAbsentMigrationVersions counts snapshot versions that no online source drive
|
||||
// holds anymore, including free versions. A pool-level NotFound is insufficient:
|
||||
// the ordinary read path can return it for inconsistent metadata. Only readable
|
||||
// per-drive metadata (or physical absence) proves progress for another walk.
|
||||
// The caller holds the object's writer lock while checking the source.
|
||||
func (er erasureObjects) countAbsentMigrationVersions(ctx context.Context, bucket, object string, versions []FileInfo) (int, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(versions) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
absent := make(map[string]struct{}, len(versions))
|
||||
for _, version := range versions {
|
||||
absent[migrationVersionKey(version.VersionID)] = struct{}{}
|
||||
}
|
||||
disks := er.getDisks()
|
||||
if len(disks) == 0 {
|
||||
return 0, toObjectErr(errErasureReadQuorum, bucket, object)
|
||||
}
|
||||
readQuorum := er.migrationReadQuorum(len(disks))
|
||||
offline := make([]bool, len(disks))
|
||||
stored := make([][]FileInfo, len(disks))
|
||||
g := errgroup.WithNErrs(len(disks))
|
||||
for i := range disks {
|
||||
g.Go(func() error {
|
||||
if disks[i] == nil {
|
||||
offline[i] = true
|
||||
return nil
|
||||
}
|
||||
raw, err := disks[i].ReadXL(ctx, bucket, object, false)
|
||||
if errors.Is(err, errDiskNotFound) {
|
||||
offline[i] = true
|
||||
return nil
|
||||
}
|
||||
if errors.Is(err, errFileNotFound) {
|
||||
// ReadXL also returns NotFound for an empty xl.meta, and may
|
||||
// ignore legacy metadata. Confirm physical absence before using
|
||||
// that result as progress. This does not descend into child keys.
|
||||
// Both reads run in parallel across drives, once per object.
|
||||
entries, err := disks[i].ListDir(ctx, "", bucket, object, -1)
|
||||
if errors.Is(err, errDiskNotFound) {
|
||||
offline[i] = true
|
||||
return nil
|
||||
}
|
||||
if err != nil && !errors.Is(err, errFileNotFound) && !errors.Is(err, errVolumeNotFound) {
|
||||
return err
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if entry == xlStorageFormatFile || entry == xlStorageFormatFileV1 {
|
||||
return errFileCorrupt
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if errors.Is(err, errVolumeNotFound) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// allParts=false omits part details, not free versions. Decode every
|
||||
// version before concluding absence, so corrupt metadata fails closed.
|
||||
fivs, err := getAllFileInfoVersions(raw.Buf, bucket, object, false)
|
||||
stored[i] = fivs.Versions
|
||||
return err
|
||||
}, i)
|
||||
}
|
||||
errs := g.Wait()
|
||||
if err := ctx.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for i, err := range errs {
|
||||
if err != nil {
|
||||
return 0, toObjectErr(err, bucket, object)
|
||||
}
|
||||
for _, version := range stored[i] {
|
||||
delete(absent, migrationVersionKey(version.VersionID))
|
||||
}
|
||||
}
|
||||
offlineCount := 0
|
||||
for _, unavailable := range offline {
|
||||
if unavailable {
|
||||
offlineCount++
|
||||
}
|
||||
}
|
||||
if len(disks)-offlineCount < readQuorum {
|
||||
return 0, toObjectErr(errErasureReadQuorum, bucket, object)
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return len(absent), nil
|
||||
}
|
||||
|
||||
// countAbsentMigrationVersions checks ignored walk entries without authorizing
|
||||
// their deletion. A concurrent client delete may have uncovered child keys.
|
||||
func (z *erasureServerPools) countAbsentMigrationVersions(ctx context.Context, bucket, object string, source int, versions []FileInfo) (int, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(versions) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
object = encodeDirObject(object)
|
||||
lk := z.NewNSLock(bucket, object)
|
||||
lkctx, err := lk.GetLock(ctx, globalOperationTimeout)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return 0, ctx.Err()
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
defer lk.Unlock(lkctx)
|
||||
return z.serverPools[source].getHashedSet(object).countAbsentMigrationVersions(lkctx.Context(), bucket, object, versions)
|
||||
}
|
||||
|
||||
// migrationObjectMetadata restores tags removed from the read representation.
|
||||
// Keep the original revision, including ordered empty states, for the existing
|
||||
|
||||
@@ -590,9 +590,9 @@ func TestSinglePoolConditionalPutHTTP(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// An internal replica callback without an addressed version is not a public
|
||||
// condition. Preserve its availability when another pool is unreadable. An
|
||||
// addressed replica already requires all pools for lock/tag reconciliation.
|
||||
// Replica overwrites must establish protection across every pool, including
|
||||
// the null version of an unversioned request. An unreadable pool may hold a
|
||||
// protected copy, so neither addressed nor unaddressed writes may replace it.
|
||||
func TestPoolsConditionalPutReplicaAvailability(t *testing.T) {
|
||||
for _, addressed := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("addressed=%t", addressed), func(t *testing.T) {
|
||||
@@ -615,10 +615,9 @@ func TestPoolsConditionalPutReplicaAvailability(t *testing.T) {
|
||||
xhttp.MinIOSourceETag: fmt.Sprintf("%x", md5.Sum([]byte("replacement"))),
|
||||
}
|
||||
url := getPutObjectURL("", bucket, object)
|
||||
wantStatus, wantBody := http.StatusOK, "replacement"
|
||||
wantStatus, wantBody := http.StatusServiceUnavailable, "old"
|
||||
if addressed {
|
||||
url += "?versionId=" + oi.VersionID
|
||||
wantStatus, wantBody = http.StatusServiceUnavailable, "old"
|
||||
}
|
||||
put := multipartConditionRequest(t, router, http.MethodPut, url, "replacement", headers)
|
||||
if put.Code != wantStatus {
|
||||
|
||||
@@ -376,6 +376,9 @@ func (z *erasureServerPools) IsPoolRebalancing(poolIndex int) bool {
|
||||
}
|
||||
|
||||
func (z *erasureServerPools) rebalanceBuckets(ctx context.Context, poolIdx int) (err error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
doneCh := make(chan error, 1)
|
||||
defer xioutil.SafeClose(doneCh)
|
||||
|
||||
@@ -450,7 +453,7 @@ func (z *erasureServerPools) rebalanceBuckets(ctx context.Context, poolIdx int)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
doneCh <- ctx.Err()
|
||||
return err
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
@@ -474,6 +477,10 @@ func (z *erasureServerPools) rebalanceBuckets(ctx context.Context, poolIdx int)
|
||||
z.bucketRebalanceDone(bucket, poolIdx)
|
||||
}
|
||||
|
||||
if err := ctx.Err(); err != nil {
|
||||
doneCh <- err
|
||||
return err
|
||||
}
|
||||
rebalanceLogEvent(GlobalContext, "Pool %d rebalancing is done", poolIdx+1)
|
||||
|
||||
return err
|
||||
@@ -538,6 +545,9 @@ func (set *erasureObjects) listObjectsToRebalance(ctx context.Context, bucketNam
|
||||
|
||||
// rebalanceBucket rebalances objects under bucket in poolIdx pool
|
||||
func (z *erasureServerPools) rebalanceBucket(ctx context.Context, bucket string, poolIdx int) (err error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
ctx = logger.SetReqInfo(ctx, &logger.ReqInfo{})
|
||||
|
||||
var vc *versioning.Versioning
|
||||
@@ -643,7 +653,7 @@ func (z *erasureServerPools) rebalanceBucket(ctx context.Context, bucket string,
|
||||
// to create the appropriate stack.
|
||||
versionsSorter(fivs.Versions).reverse()
|
||||
|
||||
var rebalanced int
|
||||
var candidates []FileInfo
|
||||
var expired []migrationExpiredVersion
|
||||
// The key's history in all pools, read once for its versions.
|
||||
var history migrationHistory
|
||||
@@ -658,12 +668,8 @@ func (z *erasureServerPools) rebalanceBucket(ctx context.Context, bucket string,
|
||||
|
||||
// Apply lifecycle rules on the objects that are expired.
|
||||
if action := filterLifecycle(bucket, version.Name, version, &history); action.Delete() {
|
||||
// Count the version, or the source copies of the moved
|
||||
// ones stay behind as duplicates. Its own source copy
|
||||
// goes with them when ILM deletes it alone; any other
|
||||
// version stays until the expiry has run.
|
||||
rebalanced++
|
||||
if ilmDeletesVersionOnly(action) || action.DeleteAll() {
|
||||
candidates = append(candidates, version)
|
||||
expired = append(expired, migrationExpiredVersion{
|
||||
version: version,
|
||||
event: lifecycle.Event{Action: action},
|
||||
@@ -706,7 +712,6 @@ func (z *erasureServerPools) rebalanceBucket(ctx context.Context, bucket string,
|
||||
// This can happen when rebalance stop races with ongoing rebalance workers.
|
||||
// These rebalance failures can be ignored.
|
||||
if isErrObjectNotFound(err) || isErrVersionNotFound(err) || isDataMovementOverWriteErr(err) {
|
||||
ignore = true
|
||||
stopFn(0, nil)
|
||||
continue
|
||||
}
|
||||
@@ -716,7 +721,7 @@ func (z *erasureServerPools) rebalanceBucket(ctx context.Context, bucket string,
|
||||
failure = err != nil
|
||||
if !failure {
|
||||
z.updatePoolStats(poolIdx, bucket, version)
|
||||
rebalanced++
|
||||
candidates = append(candidates, version)
|
||||
}
|
||||
auditLogRebalance(ctx, "Rebalance:DeleteMarker", bucket, version.Name, versionID, err)
|
||||
continue
|
||||
@@ -773,13 +778,14 @@ func (z *erasureServerPools) rebalanceBucket(ctx context.Context, bucket string,
|
||||
break // break out on first error
|
||||
}
|
||||
z.updatePoolStats(poolIdx, bucket, version)
|
||||
rebalanced++
|
||||
candidates = append(candidates, version)
|
||||
}
|
||||
|
||||
// if all versions were rebalanced, we can delete the object versions.
|
||||
if rebalanced == len(fivs.Versions) {
|
||||
// A skipped or failed sibling does not prevent retiring successful
|
||||
// moves. Cleanup still rechecks each candidate and its destination.
|
||||
if len(candidates) > 0 {
|
||||
stopFn := globalRebalanceMetrics.log(rebalanceMetricRebalanceRemoveObject, poolIdx, bucket, entry.name)
|
||||
err := z.removeMovedVersions(ctx, bucket, entry.name, poolIdx, fivs.Versions, expired)
|
||||
_, err := z.removeMovedVersions(ctx, bucket, entry.name, poolIdx, candidates, expired)
|
||||
stopFn(0, err)
|
||||
auditLogRebalance(ctx, "Rebalance:DeleteObject", bucket, entry.name, "", err)
|
||||
if err != nil {
|
||||
@@ -806,7 +812,7 @@ func (z *erasureServerPools) rebalanceBucket(ctx context.Context, bucket string,
|
||||
}
|
||||
|
||||
wk.Wait()
|
||||
return nil
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
type rebalSaveOpts uint8
|
||||
|
||||
@@ -26,6 +26,7 @@ import (
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"path"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -1194,6 +1195,7 @@ func (z *erasureServerPools) PutObject(ctx context.Context, bucket string, objec
|
||||
if opts.replicaObjectInfo == nil && (opts.ReplicaLockReconcile || !opts.ReplicationRequest) {
|
||||
opts.replicaObjectInfo = z.replicaObjectInfo
|
||||
}
|
||||
opts.replicaContentGuard = z.guardReplicaContent
|
||||
oi, err := z.serverPools[idx].PutObject(ctx, bucket, object, data, opts)
|
||||
if err == nil && opts.ReplicaLockReconcile && !opts.DataMovement {
|
||||
err = z.retireReplicaCopies(ctx, bucket, object, idx, oi)
|
||||
@@ -1543,6 +1545,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
|
||||
@@ -1700,6 +1723,7 @@ func (z *erasureServerPools) CopyObject(ctx context.Context, srcBucket, srcObjec
|
||||
stored := mergedPoolObjectInfo(copies)
|
||||
reconcileStoredObjectLock(srcInfo.UserDefined, storedObjectLockState(stored.UserDefined))
|
||||
reconcileStoredObjectTags(srcInfo.UserDefined, stored.UserTags, stored.UserDefined[ReservedMetadataPrefixLower+TaggingTimestamp])
|
||||
dstOpts.replicaObjectInfo = z.replicaObjectInfo
|
||||
idx := copies[0].Index
|
||||
oi, err := z.serverPools[idx].CopyObject(ctx, srcBucket, srcObject, dstBucket, dstObject, srcInfo, srcOpts, dstOpts)
|
||||
if err == nil {
|
||||
@@ -1727,6 +1751,9 @@ func (z *erasureServerPools) CopyObject(ctx context.Context, srcBucket, srcObjec
|
||||
return objInfo, err
|
||||
}
|
||||
|
||||
if !z.SinglePool() {
|
||||
dstOpts.replicaContentGuard = z.guardReplicaContent
|
||||
}
|
||||
if !z.SinglePool() && (dstOpts.ReplicaLockReconcile || !dstOpts.ReplicationRequest) {
|
||||
dstOpts.replicaObjectInfo = z.replicaObjectInfo
|
||||
if dstOpts.ReplicaLockReconcile && !dstOpts.DataMovement {
|
||||
@@ -1777,6 +1804,7 @@ func (z *erasureServerPools) CopyObject(ctx context.Context, srcBucket, srcObjec
|
||||
ReplicaLockReconcile: dstOpts.ReplicaLockReconcile,
|
||||
DataMovement: dstOpts.DataMovement,
|
||||
replicaObjectInfo: dstOpts.replicaObjectInfo,
|
||||
replicaContentGuard: dstOpts.replicaContentGuard,
|
||||
}
|
||||
|
||||
return z.serverPools[poolIdx].PutObject(ctx, dstBucket, dstObject, srcInfo.PutObjReader, putOpts)
|
||||
@@ -2502,6 +2530,7 @@ func (z *erasureServerPools) CompleteMultipartUpload(ctx context.Context, bucket
|
||||
opts.replicaObjectInfo = z.replicaObjectInfo
|
||||
}
|
||||
|
||||
opts.replicaContentGuard = z.guardReplicaContent
|
||||
for idx, pool := range z.serverPools {
|
||||
if z.IsSuspended(idx) {
|
||||
continue
|
||||
@@ -3345,7 +3374,7 @@ func (z *erasureServerPools) PutObjectTags(ctx context.Context, bucket, object s
|
||||
// revision beyond all copies, so the returned value and every pool agree.
|
||||
if stamp := opts.UserDefined[ReservedMetadataPrefixLower+TaggingTimestamp]; stamp != "" {
|
||||
for _, copy := range copies {
|
||||
stamp = monotonicTaggingTimestamp(stamp, copy.ObjInfo.UserDefined[ReservedMetadataPrefixLower+TaggingTimestamp])
|
||||
stamp = monotonicMetadataTimestamp(stamp, copy.ObjInfo.UserDefined[ReservedMetadataPrefixLower+TaggingTimestamp])
|
||||
}
|
||||
opts.UserDefined = cloneMSS(opts.UserDefined)
|
||||
opts.UserDefined[ReservedMetadataPrefixLower+TaggingTimestamp] = stamp
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
// Copyright (c) 2026 PGSTY
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/minio/madmin-go/v3"
|
||||
"github.com/minio/minio/internal/auth"
|
||||
"github.com/pgsty/silo-pkg/v3/policy"
|
||||
)
|
||||
|
||||
func iamImportArchive(t *testing.T, assets map[string]any) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
for name, value := range assets {
|
||||
w, err := zw.Create(pathJoin(iamAssetsDir, name))
|
||||
mustIAM(t, err)
|
||||
mustIAM(t, json.NewEncoder(w).Encode(value))
|
||||
}
|
||||
mustIAM(t, zw.Close())
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func iamImportCall(t *testing.T, data []byte) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req, err := newTestSignedRequestV4(http.MethodPut, "http://localhost/minio/admin/v3/import-iam-v2", int64(len(data)), bytes.NewReader(data), globalActiveCred.AccessKey, globalActiveCred.SecretKey, nil)
|
||||
mustIAM(t, err)
|
||||
mustIAM(t, req.ParseForm())
|
||||
rec := httptest.NewRecorder()
|
||||
adminAPIHandlers{}.ImportIAMV2(rec, req.WithContext(newContext(req, rec, "ImportIAM")))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("import-iam-v2: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestIAMImportExportState(t *testing.T) {
|
||||
for _, target := range []string{"fresh", "existing"} {
|
||||
t.Run(target, func(t *testing.T) {
|
||||
ctx, sys := iamAdminFixture(t)
|
||||
parent := iamAdminUser(ctx, t, sys, "import-parent", "")
|
||||
for _, status := range []string{statusDisabled, statusEnabled} {
|
||||
group := "import-" + status
|
||||
_, err := sys.AddUsersToGroup(ctx, group, []string{parent.AccessKey})
|
||||
mustIAM(t, err)
|
||||
_, err = sys.SetGroupStatus(ctx, group, status == statusEnabled)
|
||||
mustIAM(t, err)
|
||||
}
|
||||
_, err := sys.PolicyDBSet(ctx, "import-disabled", "readwrite", regUser, true)
|
||||
mustIAM(t, err)
|
||||
sp, err := policy.ParseConfig(strings.NewReader(svcAccEditPolicy))
|
||||
mustIAM(t, err)
|
||||
expires := UTCNow().Add(time.Hour).Truncate(time.Second)
|
||||
_, _, err = sys.NewServiceAccount(ctx, parent.AccessKey, nil, newServiceAccountOpts{
|
||||
accessKey: "import-service", secretKey: "import-service-secret", status: auth.AccountOff,
|
||||
sessionPolicy: sp, expiration: &expires, name: "disabled service", claims: map[string]any{"identity-attribute": "preserved"},
|
||||
})
|
||||
mustIAM(t, err)
|
||||
exported := iamAdminCall(t, globalActiveCred, http.MethodGet, "/minio/admin/v3/export-iam", nil, nil, adminAPIHandlers{}.ExportIAM)
|
||||
if exported.Code != http.StatusOK {
|
||||
t.Fatalf("export-iam: %d %s", exported.Code, exported.Body.String())
|
||||
}
|
||||
if target == "fresh" {
|
||||
ctx, sys = iamAdminFixture(t)
|
||||
} else {
|
||||
// Import must also replace an existing enabled account/group and
|
||||
// restore an enabled group that is disabled on the target.
|
||||
_, err = sys.UpdateServiceAccount(ctx, "import-service", updateServiceAccountOpts{status: auth.AccountOn})
|
||||
mustIAM(t, err)
|
||||
_, err = sys.SetGroupStatus(ctx, "import-disabled", true)
|
||||
mustIAM(t, err)
|
||||
_, err = sys.SetGroupStatus(ctx, "import-enabled", false)
|
||||
mustIAM(t, err)
|
||||
}
|
||||
rec := iamImportCall(t, exported.Body.Bytes())
|
||||
var result madmin.ImportIAMResult
|
||||
mustIAM(t, json.Unmarshal(rec.Body.Bytes(), &result))
|
||||
if len(result.Failed.Groups) != 0 || len(result.Failed.ServiceAccounts) != 0 || len(result.Added.ServiceAccounts) != 1 {
|
||||
t.Fatalf("import did not restore the exported entities: %s", rec.Body.String())
|
||||
}
|
||||
mustIAM(t, sys.store.LoadIAMCache(ctx, false))
|
||||
for _, status := range []string{statusDisabled, statusEnabled} {
|
||||
group, err := sys.GetGroupDescription("import-" + status)
|
||||
mustIAM(t, err)
|
||||
if group.Status != status || len(group.Members) != 1 || group.Members[0] != parent.AccessKey {
|
||||
t.Fatalf("group status or members changed: %+v", group)
|
||||
}
|
||||
}
|
||||
if sys.IsAllowed(policy.Args{AccountName: parent.AccessKey, Action: policy.GetObjectAction, BucketName: "edit", ObjectName: "object"}) {
|
||||
t.Error("the disabled imported group grants access")
|
||||
}
|
||||
cr, gotPolicy, err := sys.GetServiceAccount(ctx, "import-service")
|
||||
mustIAM(t, err)
|
||||
if cr.Status != auth.AccountOff || !cr.Expiration.Equal(expires) || cr.Name != "disabled service" || gotPolicy == nil || !gotPolicy.Equals(*sp) {
|
||||
t.Fatal("import changed the service account status, expiration, name or inline policy")
|
||||
}
|
||||
if _, valid, err := sys.CheckKey(ctx, "import-service"); err != nil || valid {
|
||||
t.Fatalf("imported disabled credential is usable: valid=%v err=%v", valid, err)
|
||||
}
|
||||
var stored UserIdentity
|
||||
mustIAM(t, sys.store.loadIAMConfig(ctx, &stored, getUserIdentityPath("import-service", svcUser)))
|
||||
raw, err := auth.ExtractClaims(stored.Credentials.SessionToken, stored.Credentials.SecretKey)
|
||||
mustIAM(t, err)
|
||||
if _, ok := raw.Map()[sessionPolicyNameExtracted]; ok || raw.Map()["identity-attribute"] != "preserved" {
|
||||
t.Fatal("import polluted the raw JWT or lost identity claims")
|
||||
}
|
||||
// Old exports may contain a derived claim and omit the policy version.
|
||||
snapshot, err := serviceAccountSnapshot(stored)
|
||||
mustIAM(t, err)
|
||||
snapshot.Claims[sessionPolicyNameExtracted] = "stale decoded policy"
|
||||
snapshot.SessionPolicy = []byte(strings.ReplaceAll(svcAccEditPolicy, `"Version":"2012-10-17",`, ""))
|
||||
rec = iamImportCall(t, iamImportArchive(t, map[string]any{allSvcAcctsFile: map[string]madmin.SRSvcAccCreate{"import-service": snapshot}}))
|
||||
mustIAM(t, json.Unmarshal(rec.Body.Bytes(), &result))
|
||||
if len(result.Failed.ServiceAccounts) != 0 {
|
||||
t.Fatalf("legacy snapshot import failed: %s", rec.Body.String())
|
||||
}
|
||||
_, gotPolicy, err = sys.GetServiceAccount(ctx, "import-service")
|
||||
mustIAM(t, err)
|
||||
if gotPolicy == nil || !gotPolicy.Equals(*sp) {
|
||||
t.Fatal("import did not normalize the inline policy")
|
||||
}
|
||||
mustIAM(t, sys.store.loadIAMConfig(ctx, &stored, getUserIdentityPath("import-service", svcUser)))
|
||||
raw, err = auth.ExtractClaims(stored.Credentials.SessionToken, stored.Credentials.SecretKey)
|
||||
mustIAM(t, err)
|
||||
if _, ok := raw.Map()[sessionPolicyNameExtracted]; ok {
|
||||
t.Fatal("legacy import signed a derived claim into the JWT")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type iamImportGroupFailureStore struct {
|
||||
IAMStorageAPI
|
||||
statusOnly bool
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *iamImportGroupFailureStore) saveIAMConfig(ctx context.Context, item any, path string, opts ...options) error {
|
||||
mutation, _ := ctx.Value(iamGroupMutationKey{}).(iamGroupMutation)
|
||||
if path == getGroupInfoPath("import-failure") && mutation.StatusOnly == s.statusOnly {
|
||||
return s.err
|
||||
}
|
||||
return s.IAMStorageAPI.saveIAMConfig(ctx, item, path, opts...)
|
||||
}
|
||||
|
||||
func TestIAMImportGroupFailure(t *testing.T) {
|
||||
for _, operation := range []string{"members", "status"} {
|
||||
t.Run(operation, func(t *testing.T) {
|
||||
_, sys := iamAdminFixture(t)
|
||||
injected := madmin.ErrorResponse{Code: "InjectedGroupWrite", Message: operation + " write failed"}
|
||||
sys.store.IAMStorageAPI = &iamImportGroupFailureStore{IAMStorageAPI: sys.store.IAMStorageAPI, statusOnly: operation == "status", err: injected}
|
||||
rec := iamImportCall(t, iamImportArchive(t, map[string]any{allGroupsFile: map[string]GroupInfo{
|
||||
"import-failure": {Status: statusDisabled},
|
||||
}}))
|
||||
var result struct {
|
||||
Added madmin.IAMEntities `json:"added"`
|
||||
Failed struct {
|
||||
Groups []struct {
|
||||
Name string `json:"name"`
|
||||
Error json.RawMessage `json:"error"`
|
||||
} `json:"groups"`
|
||||
} `json:"failed"`
|
||||
}
|
||||
mustIAM(t, json.Unmarshal(rec.Body.Bytes(), &result))
|
||||
wantError, err := json.Marshal(injected)
|
||||
mustIAM(t, err)
|
||||
if len(result.Added.Groups) != 0 || len(result.Failed.Groups) != 1 || result.Failed.Groups[0].Name != "import-failure" || !bytes.Equal(result.Failed.Groups[0].Error, wantError) {
|
||||
t.Fatalf("import did not report the actual %s error: %s", operation, rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
// Copyright (c) 2026 PGSTY
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/minio/minio/internal/auth"
|
||||
)
|
||||
|
||||
// The LDAP group refresh computes groups from a snapshot of a server's cache
|
||||
// taken before a slow directory lookup, then writes them. These tests take
|
||||
// such a snapshot, change the credential the way an administrator would on
|
||||
// this or another server, and run the write: it must change only the
|
||||
// groups of what is stored now, and never undo the change.
|
||||
|
||||
type groupRefreshFixture struct {
|
||||
ctx context.Context
|
||||
sys *IAMSys
|
||||
obj ObjectLayer
|
||||
parent auth.Credentials
|
||||
}
|
||||
|
||||
func newGroupRefreshFixture(t *testing.T) *groupRefreshFixture {
|
||||
t.Helper()
|
||||
ctx, sys, obj := prepareIAMRevisionFixture(t)
|
||||
batchAuthzPolicy(ctx, t, sys, "refresh-parent", batchAuthzFullAccess)
|
||||
parent := iamAdminUser(ctx, t, sys, "refresh-parent", "refresh-parent")
|
||||
return &groupRefreshFixture{ctx: ctx, sys: sys, obj: obj, parent: parent}
|
||||
}
|
||||
|
||||
// service creates a service account of the fixture's parent and returns
|
||||
// its stored credential, the snapshot a refresh would take.
|
||||
func (f *groupRefreshFixture) service(t *testing.T, accessKey string) auth.Credentials {
|
||||
t.Helper()
|
||||
_, _, err := f.sys.NewServiceAccount(f.ctx, f.parent.AccessKey, nil, newServiceAccountOpts{accessKey: accessKey, secretKey: "refresh-original-secret"})
|
||||
mustIAM(t, err)
|
||||
u, ok := f.sys.store.GetUser(accessKey)
|
||||
if !ok {
|
||||
t.Fatal("service account missing")
|
||||
}
|
||||
return u.Credentials
|
||||
}
|
||||
|
||||
// sibling is another server: the same storage, its own cache.
|
||||
func (f *groupRefreshFixture) sibling(t *testing.T) *IAMStoreSys {
|
||||
t.Helper()
|
||||
s := &IAMStoreSys{IAMStorageAPI: newIAMObjectStore(f.obj, f.sys.usersSysType)}
|
||||
mustIAM(t, s.LoadIAMCache(f.ctx, false))
|
||||
return s
|
||||
}
|
||||
|
||||
// stored is the record as a server that starts now loads it.
|
||||
func (f *groupRefreshFixture) stored(t *testing.T, accessKey string) (UserIdentity, bool) {
|
||||
t.Helper()
|
||||
return f.sibling(t).GetUser(accessKey)
|
||||
}
|
||||
|
||||
func TestIAMGroupRefreshKeepsRotation(t *testing.T) {
|
||||
f := newGroupRefreshFixture(t)
|
||||
snapshot := f.service(t, "refresh-rotate")
|
||||
_, err := f.sys.UpdateServiceAccount(f.ctx, snapshot.AccessKey, updateServiceAccountOpts{secretKey: "refresh-rotated-secret", description: "rotated"})
|
||||
mustIAM(t, err)
|
||||
|
||||
wrote, err := f.sys.store.RefreshUserGroups(f.ctx, snapshot, []string{"g1"})
|
||||
mustIAM(t, err)
|
||||
if !wrote {
|
||||
t.Fatal("a refresh of a live credential must write its new groups")
|
||||
}
|
||||
check := func(where string, u UserIdentity, ok bool) {
|
||||
t.Helper()
|
||||
switch {
|
||||
case !ok:
|
||||
t.Fatalf("%s: credential missing", where)
|
||||
case u.Credentials.SecretKey != "refresh-rotated-secret":
|
||||
t.Errorf("%s: the refresh undid the rotation", where)
|
||||
case u.Credentials.Description != "rotated":
|
||||
t.Errorf("%s: the refresh undid an edit", where)
|
||||
case !slices.Equal(u.Credentials.Groups, []string{"g1"}):
|
||||
t.Errorf("%s: groups %v, want [g1]", where, u.Credentials.Groups)
|
||||
}
|
||||
}
|
||||
u, ok := f.sys.store.GetUser(snapshot.AccessKey)
|
||||
check("cache", u, ok)
|
||||
u, ok = f.stored(t, snapshot.AccessKey)
|
||||
check("storage", u, ok)
|
||||
if _, valid, _ := f.sys.CheckKey(f.ctx, snapshot.AccessKey); !valid {
|
||||
t.Error("the rotated credential must stay valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIAMGroupRefreshKeepsDeletion(t *testing.T) {
|
||||
f := newGroupRefreshFixture(t)
|
||||
snapshot := f.service(t, "refresh-delete")
|
||||
mustIAM(t, f.sys.DeleteServiceAccount(f.ctx, snapshot.AccessKey, false))
|
||||
|
||||
wrote, err := f.sys.store.RefreshUserGroups(f.ctx, snapshot, []string{"g1"})
|
||||
mustIAM(t, err)
|
||||
if wrote {
|
||||
t.Error("a refresh wrote a deleted credential")
|
||||
}
|
||||
if _, ok := f.sys.store.GetUser(snapshot.AccessKey); ok {
|
||||
t.Error("a refresh brought a deleted credential back into the cache")
|
||||
}
|
||||
if _, ok := f.stored(t, snapshot.AccessKey); ok {
|
||||
t.Error("a refresh brought a deleted credential back in storage")
|
||||
}
|
||||
}
|
||||
|
||||
// Another server changed the credential and this server's cache has not
|
||||
// heard of it: its snapshot and its cache are both stale.
|
||||
func TestIAMGroupRefreshStaleSiblingCache(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
change func(t *testing.T, f *groupRefreshFixture, accessKey string)
|
||||
check func(t *testing.T, u UserIdentity, ok bool)
|
||||
}{
|
||||
{"rotated", func(t *testing.T, f *groupRefreshFixture, accessKey string) {
|
||||
_, err := f.sys.UpdateServiceAccount(f.ctx, accessKey, updateServiceAccountOpts{secretKey: "refresh-rotated-secret"})
|
||||
mustIAM(t, err)
|
||||
}, func(t *testing.T, u UserIdentity, ok bool) {
|
||||
if !ok || u.Credentials.SecretKey != "refresh-rotated-secret" {
|
||||
t.Errorf("a stale server undid the rotation of another (present=%v)", ok)
|
||||
}
|
||||
}},
|
||||
{"deleted", func(t *testing.T, f *groupRefreshFixture, accessKey string) {
|
||||
mustIAM(t, f.sys.DeleteServiceAccount(f.ctx, accessKey, false))
|
||||
}, func(t *testing.T, u UserIdentity, ok bool) {
|
||||
if ok {
|
||||
t.Error("a stale server brought back a credential another deleted")
|
||||
}
|
||||
}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
f := newGroupRefreshFixture(t)
|
||||
snapshot := f.service(t, "refresh-sib-"+tc.name)
|
||||
other := f.sibling(t)
|
||||
stale, ok := other.GetUser(snapshot.AccessKey)
|
||||
if !ok {
|
||||
t.Fatal("sibling fixture did not load the credential")
|
||||
}
|
||||
tc.change(t, f, snapshot.AccessKey)
|
||||
if cached, _ := other.GetUser(snapshot.AccessKey); cached.Credentials.SecretKey != stale.Credentials.SecretKey {
|
||||
t.Fatal("control: the sibling cache must not have heard of the change")
|
||||
}
|
||||
_, err := other.RefreshUserGroups(f.ctx, stale.Credentials, []string{"g1"})
|
||||
mustIAM(t, err)
|
||||
u, ok := f.stored(t, snapshot.AccessKey)
|
||||
tc.check(t, u, ok)
|
||||
// The refreshing server's own cache follows storage too: it holds
|
||||
// the stored record, or nothing, so a lookup loads it.
|
||||
u, ok = other.GetUser(snapshot.AccessKey)
|
||||
tc.check(t, u, ok)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A stored record may lack its access key; the loaders take it from the
|
||||
// path, and so does the refresh. Such a credential still gets its groups,
|
||||
// including a removal.
|
||||
func TestIAMGroupRefreshRecordWithoutAccessKey(t *testing.T) {
|
||||
f := newGroupRefreshFixture(t)
|
||||
snapshot := f.service(t, "refresh-nokey")
|
||||
path := getUserIdentityPath(snapshot.AccessKey, svcUser)
|
||||
var u UserIdentity
|
||||
mustIAM(t, f.sys.store.loadIAMConfig(f.ctx, &u, path))
|
||||
u.Credentials.AccessKey = ""
|
||||
u.Credentials.Groups = []string{"admins"}
|
||||
mustIAM(t, f.sys.store.saveIAMConfig(f.ctx, &u, path))
|
||||
loaded, ok := f.stored(t, snapshot.AccessKey)
|
||||
if !ok || loaded.Credentials.AccessKey != snapshot.AccessKey {
|
||||
t.Fatal("control: the loaders must take the access key from the path")
|
||||
}
|
||||
wrote, err := f.sys.store.RefreshUserGroups(f.ctx, loaded.Credentials, []string{"users"})
|
||||
mustIAM(t, err)
|
||||
after, ok := f.stored(t, snapshot.AccessKey)
|
||||
if !wrote || !ok || !slices.Equal(after.Credentials.Groups, []string{"users"}) {
|
||||
t.Errorf("wrote=%v present=%v groups=%v: a record without its access key must still lose a group", wrote, ok, after.Credentials.Groups)
|
||||
}
|
||||
}
|
||||
|
||||
// The access key was deleted and created again. Under the same parent the
|
||||
// groups the lookup computed are that parent's, and the new record gets
|
||||
// them with everything else it has; under another parent it is left alone.
|
||||
func TestIAMGroupRefreshRecreated(t *testing.T) {
|
||||
f := newGroupRefreshFixture(t)
|
||||
snapshot := f.service(t, "refresh-recreate")
|
||||
mustIAM(t, f.sys.DeleteServiceAccount(f.ctx, snapshot.AccessKey, false))
|
||||
_, _, err := f.sys.NewServiceAccount(f.ctx, f.parent.AccessKey, nil, newServiceAccountOpts{accessKey: snapshot.AccessKey, secretKey: "refresh-original-secret", description: "recreated"})
|
||||
mustIAM(t, err)
|
||||
_, err = f.sys.store.RefreshUserGroups(f.ctx, snapshot, []string{"g1"})
|
||||
mustIAM(t, err)
|
||||
u, ok := f.stored(t, snapshot.AccessKey)
|
||||
if !ok || u.Credentials.Description != "recreated" || !slices.Equal(u.Credentials.Groups, []string{"g1"}) {
|
||||
t.Errorf("same parent: present=%v description=%q groups=%v, want the recreated record with [g1]", ok, u.Credentials.Description, u.Credentials.Groups)
|
||||
}
|
||||
|
||||
other := iamAdminUser(f.ctx, t, f.sys, "refresh-other-parent", "refresh-parent")
|
||||
mustIAM(t, f.sys.DeleteServiceAccount(f.ctx, snapshot.AccessKey, false))
|
||||
_, _, err = f.sys.NewServiceAccount(f.ctx, other.AccessKey, nil, newServiceAccountOpts{accessKey: snapshot.AccessKey, secretKey: "refresh-other-secret"})
|
||||
mustIAM(t, err)
|
||||
wrote, err := f.sys.store.RefreshUserGroups(f.ctx, snapshot, []string{"g2"})
|
||||
mustIAM(t, err)
|
||||
u, ok = f.stored(t, snapshot.AccessKey)
|
||||
if wrote || !ok || u.Credentials.ParentUser != other.AccessKey || slices.Contains(u.Credentials.Groups, "g2") {
|
||||
t.Errorf("another parent: wrote=%v present=%v parent=%q groups=%v, want it left alone", wrote, ok, u.Credentials.ParentUser, u.Credentials.Groups)
|
||||
}
|
||||
}
|
||||
|
||||
// Groups the record already has are not written again.
|
||||
func TestIAMGroupRefreshUnchanged(t *testing.T) {
|
||||
f := newGroupRefreshFixture(t)
|
||||
snapshot := f.service(t, "refresh-unchanged")
|
||||
_, err := f.sys.store.RefreshUserGroups(f.ctx, snapshot, []string{"g1"})
|
||||
mustIAM(t, err)
|
||||
before, _ := f.stored(t, snapshot.AccessKey)
|
||||
wrote, err := f.sys.store.RefreshUserGroups(f.ctx, snapshot, []string{"g1"})
|
||||
mustIAM(t, err)
|
||||
after, _ := f.stored(t, snapshot.AccessKey)
|
||||
if wrote || !after.UpdatedAt.Equal(before.UpdatedAt) {
|
||||
t.Errorf("wrote=%v, revision %s -> %s: unchanged groups must not be written", wrote, before.UpdatedAt, after.UpdatedAt)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
// Copyright (c) 2026 PGSTY
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"slices"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/pgsty/silo-pkg/v3/policy"
|
||||
)
|
||||
|
||||
// Hold a lazy user load after it has read storage. This makes the ordering
|
||||
// between an in-flight read and a completed credential edit deterministic.
|
||||
type iamLoadUserBlocker struct {
|
||||
IAMStorageAPI
|
||||
key string
|
||||
waiting chan struct{}
|
||||
release chan struct{}
|
||||
once sync.Once
|
||||
loads atomic.Int32
|
||||
retryWaiting chan struct{}
|
||||
retryRelease chan struct{}
|
||||
retryErr error
|
||||
}
|
||||
|
||||
func (s *iamLoadUserBlocker) loadUser(ctx context.Context, key string, userType IAMUserType, out map[string]UserIdentity) error {
|
||||
if key == s.key && userType == regUser && s.loads.Add(1) == 2 {
|
||||
if s.retryWaiting != nil {
|
||||
close(s.retryWaiting)
|
||||
select {
|
||||
case <-s.retryRelease:
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
if s.retryErr != nil {
|
||||
return s.retryErr
|
||||
}
|
||||
}
|
||||
err := s.IAMStorageAPI.loadUser(ctx, key, userType, out)
|
||||
if err == nil && key == s.key && userType == regUser {
|
||||
s.once.Do(func() { close(s.waiting) })
|
||||
select {
|
||||
case <-s.release:
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func TestIAMLoadUserConcurrentUpdates(t *testing.T) {
|
||||
for _, change := range []string{"unrelated-edit", "unrelated-load", "delete", "mapping", "policy"} {
|
||||
t.Run(change, func(t *testing.T) {
|
||||
ctx, sys, _ := prepareIAMRevisionFixture(t)
|
||||
const policyName = "lazy-policy"
|
||||
batchAuthzPolicy(ctx, t, sys, policyName, `{"Effect":"Allow","Action":["s3:*"],"Resource":["arn:aws:s3:::bucket/*"]}`)
|
||||
cold := iamAdminUser(ctx, t, sys, "lazy-cold", policyName)
|
||||
other := iamAdminUser(ctx, t, sys, "lazy-other", "readwrite")
|
||||
cache := sys.store.lock()
|
||||
delete(cache.iamUsersMap, cold.AccessKey)
|
||||
sys.store.unlock()
|
||||
original := sys.store.IAMStorageAPI
|
||||
blocker := &iamLoadUserBlocker{
|
||||
IAMStorageAPI: original, key: cold.AccessKey,
|
||||
waiting: make(chan struct{}), release: make(chan struct{}),
|
||||
}
|
||||
sys.store.IAMStorageAPI = blocker
|
||||
unblock := sync.OnceFunc(func() { close(blocker.release) })
|
||||
done := make(chan struct{})
|
||||
t.Cleanup(func() {
|
||||
unblock()
|
||||
<-done
|
||||
sys.store.IAMStorageAPI = original
|
||||
})
|
||||
var valid bool
|
||||
var loadErr error
|
||||
go func() {
|
||||
defer close(done)
|
||||
_, valid, loadErr = sys.CheckKey(ctx, cold.AccessKey)
|
||||
}()
|
||||
select {
|
||||
case <-blocker.waiting:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("cold load did not read the identity")
|
||||
}
|
||||
switch change {
|
||||
case "unrelated-edit":
|
||||
mustIAM(t, sys.SetUserSecretKey(ctx, other.AccessKey, "unrelated-rotated-secret"))
|
||||
case "unrelated-load":
|
||||
mustIAM(t, sys.store.LoadUser(ctx, other.AccessKey))
|
||||
case "delete":
|
||||
mustIAM(t, sys.DeleteUser(ctx, cold.AccessKey, false))
|
||||
case "mapping":
|
||||
_, err := sys.PolicyDBSet(ctx, cold.AccessKey, "readonly", regUser, false)
|
||||
mustIAM(t, err)
|
||||
case "policy":
|
||||
batchAuthzPolicy(ctx, t, sys, policyName, `{"Effect":"Deny","Action":["s3:*"],"Resource":["arn:aws:s3:::bucket/*"]}`)
|
||||
}
|
||||
unblock()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("cold load did not finish")
|
||||
}
|
||||
if loadErr != nil || valid != (change != "delete") {
|
||||
t.Fatalf("valid=%v err=%v after %s", valid, loadErr, change)
|
||||
}
|
||||
if blocker.loads.Load() != 2 {
|
||||
t.Fatalf("conflicting load read the identity %d times, want 2", blocker.loads.Load())
|
||||
}
|
||||
if change == "mapping" {
|
||||
names, err := sys.store.PolicyDBGet(cold.AccessKey)
|
||||
mustIAM(t, err)
|
||||
if !slices.Equal(names, []string{"readonly"}) {
|
||||
t.Fatalf("restored stale mapping: %v", names)
|
||||
}
|
||||
}
|
||||
if change == "policy" {
|
||||
current, err := sys.store.GetPolicy(policyName)
|
||||
mustIAM(t, err)
|
||||
if current.IsAllowed(policy.Args{Action: policy.PutObjectAction, BucketName: "bucket", ObjectName: "object"}) {
|
||||
t.Fatal("restored stale policy")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIAMLoadUserRetryKeepsCachedReadsAvailable(t *testing.T) {
|
||||
for _, unavailable := range []bool{false, true} {
|
||||
t.Run(map[bool]string{false: "loaded", true: "unavailable"}[unavailable], func(t *testing.T) {
|
||||
ctx, sys, _ := prepareIAMRevisionFixture(t)
|
||||
cold := iamAdminUser(ctx, t, sys, "retry-cold", "readwrite")
|
||||
other := iamAdminUser(ctx, t, sys, "retry-cached", "readwrite")
|
||||
cache := sys.store.lock()
|
||||
delete(cache.iamUsersMap, cold.AccessKey)
|
||||
sys.store.unlock()
|
||||
original := sys.store.IAMStorageAPI
|
||||
blocker := &iamLoadUserBlocker{
|
||||
IAMStorageAPI: original, key: cold.AccessKey,
|
||||
waiting: make(chan struct{}), release: make(chan struct{}),
|
||||
retryWaiting: make(chan struct{}), retryRelease: make(chan struct{}),
|
||||
}
|
||||
if unavailable {
|
||||
blocker.retryErr = errErasureReadQuorum
|
||||
}
|
||||
sys.store.IAMStorageAPI = blocker
|
||||
unblock := sync.OnceFunc(func() { close(blocker.release) })
|
||||
unblockRetry := sync.OnceFunc(func() { close(blocker.retryRelease) })
|
||||
done := make(chan struct{})
|
||||
t.Cleanup(func() {
|
||||
unblock()
|
||||
unblockRetry()
|
||||
<-done
|
||||
sys.store.IAMStorageAPI = original
|
||||
})
|
||||
var valid bool
|
||||
var loadErr error
|
||||
go func() {
|
||||
defer close(done)
|
||||
_, valid, loadErr = sys.CheckKey(ctx, cold.AccessKey)
|
||||
}()
|
||||
awaitIAMPolicyReload(t, blocker.waiting)
|
||||
mustIAM(t, sys.SetUserSecretKey(ctx, other.AccessKey, "retry-rotated-secret"))
|
||||
unblock()
|
||||
awaitIAMPolicyReload(t, blocker.retryWaiting)
|
||||
readDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(readDone)
|
||||
user, ok, err := sys.CheckKey(ctx, other.AccessKey)
|
||||
if err != nil || !ok || user.Credentials.SecretKey != "retry-rotated-secret" {
|
||||
t.Errorf("cached read failed: valid=%v err=%v", ok, err)
|
||||
}
|
||||
}()
|
||||
select {
|
||||
case <-readDone:
|
||||
case <-time.After(time.Second):
|
||||
unblockRetry()
|
||||
<-readDone
|
||||
t.Fatal("cold-load retry blocked cached authentication")
|
||||
}
|
||||
unblockRetry()
|
||||
<-done
|
||||
if unavailable {
|
||||
if valid || !errors.Is(loadErr, errIAMNotInitialized) {
|
||||
t.Fatalf("retry outage was not retryable: valid=%v err=%v", valid, loadErr)
|
||||
}
|
||||
} else if !valid || loadErr != nil {
|
||||
t.Fatalf("retry lost the cold identity: valid=%v err=%v", valid, loadErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIAMLoadUserRefreshesAssociatedRecords(t *testing.T) {
|
||||
t.Setenv("_MINIO_IAM_GROUP_REFRESH", "on")
|
||||
for _, change := range []string{"policy", "mapping", "group", "parent"} {
|
||||
t.Run(change, func(t *testing.T) {
|
||||
ctx, sys, obj := prepareIAMRevisionFixture(t)
|
||||
const policyName = "associated-policy"
|
||||
const groupName = "associated-group"
|
||||
batchAuthzPolicy(ctx, t, sys, policyName, `{"Effect":"Allow","Action":["s3:*"],"Resource":["arn:aws:s3:::bucket/*"]}`)
|
||||
parent := iamAdminUser(ctx, t, sys, "associated-parent", policyName)
|
||||
_, err := sys.AddUsersToGroup(ctx, groupName, []string{parent.AccessKey})
|
||||
mustIAM(t, err)
|
||||
key := parent.AccessKey
|
||||
if change == "parent" {
|
||||
key = iamAdminServiceAccount(ctx, t, sys, parent.AccessKey, "associated-service", "").AccessKey
|
||||
}
|
||||
gate := &iamPolicyRefreshReadGate{
|
||||
ObjectLayer: obj, userPath: getGroupInfoPath(groupName),
|
||||
refreshEntered: make(chan struct{}), refreshRelease: make(chan struct{}),
|
||||
}
|
||||
sys.store.IAMStorageAPI.(*IAMObjectStore).objAPI = gate
|
||||
unblock := sync.OnceFunc(func() { close(gate.refreshRelease) })
|
||||
done := make(chan struct{})
|
||||
var loadErr error
|
||||
t.Cleanup(func() {
|
||||
unblock()
|
||||
<-done
|
||||
})
|
||||
go func() {
|
||||
defer close(done)
|
||||
loadErr = sys.store.LoadUser(context.WithValue(ctx, iamPolicyRefreshContext{}, "refresh"), key)
|
||||
}()
|
||||
awaitIAMPolicyReload(t, gate.refreshEntered)
|
||||
switch change {
|
||||
case "policy":
|
||||
batchAuthzPolicy(ctx, t, sys, policyName, `{"Effect":"Deny","Action":["s3:*"],"Resource":["arn:aws:s3:::bucket/*"]}`)
|
||||
case "mapping":
|
||||
_, err = sys.PolicyDBSet(ctx, parent.AccessKey, "readonly", regUser, false)
|
||||
mustIAM(t, err)
|
||||
case "group":
|
||||
_, err = sys.RemoveUsersFromGroup(ctx, groupName, []string{parent.AccessKey})
|
||||
mustIAM(t, err)
|
||||
case "parent":
|
||||
mustIAM(t, sys.SetUserSecretKey(ctx, parent.AccessKey, "associated-rotated-secret"))
|
||||
}
|
||||
unblock()
|
||||
select {
|
||||
case <-done:
|
||||
mustIAM(t, loadErr)
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("associated-record reload did not finish")
|
||||
}
|
||||
switch change {
|
||||
case "policy":
|
||||
current, err := sys.store.GetPolicy(policyName)
|
||||
mustIAM(t, err)
|
||||
if current.IsAllowed(policy.Args{Action: policy.PutObjectAction, BucketName: "bucket", ObjectName: "object"}) {
|
||||
t.Fatal("partial snapshot restored old policy")
|
||||
}
|
||||
case "mapping":
|
||||
mapping, ok := sys.store.GetMappedPolicy(parent.AccessKey, false)
|
||||
if !ok || mapping.Policies != "readonly" {
|
||||
t.Fatalf("partial snapshot restored old mapping: %+v", mapping)
|
||||
}
|
||||
case "group":
|
||||
cache := sys.store.rlock()
|
||||
member := cache.iamUserGroupMemberships[parent.AccessKey].Contains(groupName)
|
||||
sys.store.runlock()
|
||||
if member {
|
||||
t.Fatal("partial snapshot restored old group membership")
|
||||
}
|
||||
case "parent":
|
||||
current, valid, err := sys.CheckKey(ctx, parent.AccessKey)
|
||||
if err != nil || !valid || current.Credentials.SecretKey != "associated-rotated-secret" {
|
||||
t.Fatal("child reload restored old parent credential")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIAMLoadUserDoesNotRestoreRotatedCredential(t *testing.T) {
|
||||
ctx, sys, _ := prepareIAMRevisionFixture(t)
|
||||
user := iamAdminUser(ctx, t, sys, "load-user-race", "readwrite")
|
||||
original := sys.store.IAMStorageAPI
|
||||
blocker := &iamLoadUserBlocker{
|
||||
IAMStorageAPI: original,
|
||||
key: user.AccessKey,
|
||||
waiting: make(chan struct{}),
|
||||
release: make(chan struct{}),
|
||||
}
|
||||
sys.store.IAMStorageAPI = blocker
|
||||
t.Cleanup(func() { sys.store.IAMStorageAPI = original })
|
||||
|
||||
loadDone := make(chan error, 1)
|
||||
go func() { loadDone <- sys.store.LoadUser(ctx, user.AccessKey) }()
|
||||
select {
|
||||
case <-blocker.waiting:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("LoadUser did not reach the controlled read barrier")
|
||||
}
|
||||
|
||||
const rotatedSecret = "load-user-rotated-secret"
|
||||
if err := sys.SetUserSecretKey(ctx, user.AccessKey, rotatedSecret); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
current, valid, err := sys.CheckKey(ctx, user.AccessKey)
|
||||
if err != nil || !valid || current.Credentials.SecretKey != rotatedSecret {
|
||||
t.Fatalf("rotation did not update the serving cache: valid=%v secret=%q err=%v", valid, current.Credentials.SecretKey, err)
|
||||
}
|
||||
|
||||
close(blocker.release)
|
||||
select {
|
||||
case err := <-loadDone:
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("LoadUser did not finish after the read barrier was released")
|
||||
}
|
||||
loaded, valid, err := sys.CheckKey(ctx, user.AccessKey)
|
||||
if err != nil || !valid || loaded.Credentials.SecretKey != rotatedSecret {
|
||||
t.Fatalf("an in-flight LoadUser restored the old credential: valid=%v secret=%q err=%v", valid, loaded.Credentials.SecretKey, err)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -45,6 +45,35 @@ type iamGroupRevocation struct {
|
||||
// its revision so an older non-expiring credential cannot return.
|
||||
type iamExpireIdentity struct{}
|
||||
|
||||
// An edit of an identity applies to the identity as it is stored now, under
|
||||
// its revision lock, not to a server's cached copy. A copy written back
|
||||
// whole would undo a rotation or an edit made meanwhile through this or
|
||||
// another server, whose notification this one may have missed, and bring
|
||||
// back an identity deleted meanwhile. edit receives the stored record and
|
||||
// changes it in place; it returns errIAMIdentityUnchanged to write nothing,
|
||||
// or any other error to refuse. An identity that is absent or deleted is
|
||||
// refused with errIAMIdentityGone.
|
||||
type iamIdentityEdit struct {
|
||||
UserIdentity // the record written, or found unchanged
|
||||
accessKey string
|
||||
edit func(u *UserIdentity) error
|
||||
unchanged bool
|
||||
}
|
||||
|
||||
// An identity create is checked against the record under the same revision
|
||||
// lock as the write. Local callers may recreate a deleted access key, but may
|
||||
// never replace a live stored service account that their cache did not contain.
|
||||
// Replicated creates retain the normal revision ordering below and may replace
|
||||
// an older live snapshot when their source revision wins.
|
||||
type iamIdentityCreate struct {
|
||||
UserIdentity
|
||||
}
|
||||
|
||||
var (
|
||||
errIAMIdentityGone = errors.New("IAM identity is absent or deleted in storage")
|
||||
errIAMIdentityUnchanged = errors.New("IAM identity edit changes nothing")
|
||||
)
|
||||
|
||||
// The authoritative revocation is durable even if dependent cleanup fails.
|
||||
// Callers must publish it to sibling caches before returning the error.
|
||||
type iamCommittedCleanupError struct {
|
||||
@@ -252,6 +281,69 @@ func saveIAMRevision(ctx context.Context, store IAMStorageAPI, path string, item
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if op, ok := item.(*iamIdentityCreate); ok {
|
||||
if _, replicated := iamReplicationTime(ctx); !replicated {
|
||||
var current UserIdentity
|
||||
err := store.loadIAMConfig(ctx, ¤t, path)
|
||||
switch {
|
||||
case err == nil && !current.Deleted:
|
||||
return fmt.Errorf("%w: access key already exists in storage", errIAMServiceAccountNotAllowed)
|
||||
case err == nil, errors.Is(err, errConfigNotFound):
|
||||
// A tombstone or an absent record can be recreated. The
|
||||
// existing revision path below retains its revocation boundary.
|
||||
default:
|
||||
return err
|
||||
}
|
||||
}
|
||||
item = &op.UserIdentity
|
||||
}
|
||||
if op, ok := item.(*iamIdentityEdit); ok {
|
||||
var current UserIdentity
|
||||
err := store.loadIAMConfig(ctx, ¤t, path)
|
||||
if errors.Is(err, errConfigNotFound) {
|
||||
return errIAMIdentityGone
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if current.Deleted {
|
||||
if origin, replicated := iamReplicationTime(ctx); replicated && !origin.After(previous.timestamp()) {
|
||||
// A peer's edit from before this deletion: acknowledged.
|
||||
return errIAMStaleUpdate
|
||||
}
|
||||
return errIAMIdentityGone
|
||||
}
|
||||
// Normalize the record as the identity loaders do: the path names the
|
||||
// access key, the claims are the token's, and a deprecated comment
|
||||
// stands in for an empty description.
|
||||
if current.Credentials.AccessKey == "" {
|
||||
current.Credentials.AccessKey = op.accessKey
|
||||
}
|
||||
if current.Credentials.SessionToken != "" {
|
||||
claims, err := extractJWTClaims(current)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
current.Credentials.Claims = claims.Map()
|
||||
}
|
||||
if current.Credentials.Description == "" {
|
||||
current.Credentials.Description = current.Credentials.Comment
|
||||
}
|
||||
if err := op.edit(¤t); err != nil {
|
||||
if errors.Is(err, errIAMIdentityUnchanged) {
|
||||
op.UserIdentity, op.unchanged = current, true
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
current.UpdatedAt = time.Time{} // a new revision, as for any local edit
|
||||
op.UserIdentity = current
|
||||
item = &op.UserIdentity
|
||||
if strings.HasPrefix(path, iamConfigSTSPrefix) {
|
||||
// Keep the record expiring with its token, as SetTempUser stores it.
|
||||
opts = []options{{ttl: max(1, int64(time.Until(current.Credentials.Expiration).Seconds()))}}
|
||||
}
|
||||
}
|
||||
if _, expiring := item.(*iamExpireIdentity); expiring {
|
||||
sts := strings.HasPrefix(path, iamConfigSTSPrefix)
|
||||
if previous.Deleted {
|
||||
|
||||
+173
-118
@@ -20,7 +20,6 @@ package cmd
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
@@ -2778,37 +2777,26 @@ func (store *IAMStoreSys) SetUserStatus(ctx context.Context, accessKey string, s
|
||||
cache := store.lock()
|
||||
defer store.unlock()
|
||||
|
||||
ui, ok := cache.iamUsersMap[accessKey]
|
||||
if !ok {
|
||||
return updatedAt, errNoSuchUser
|
||||
}
|
||||
cred := ui.Credentials
|
||||
|
||||
if cred.IsTemp() || cred.IsServiceAccount() {
|
||||
if ui, ok := cache.iamUsersMap[accessKey]; ok && (ui.Credentials.IsTemp() || ui.Credentials.IsServiceAccount()) {
|
||||
return updatedAt, errIAMActionNotAllowed
|
||||
}
|
||||
|
||||
uinfo := newUserIdentity(auth.Credentials{
|
||||
AccessKey: accessKey,
|
||||
SecretKey: cred.SecretKey,
|
||||
Status: func() string {
|
||||
switch string(status) {
|
||||
case string(madmin.AccountEnabled), string(auth.AccountOn):
|
||||
return auth.AccountOn
|
||||
u, _, err := store.editStoredIdentity(ctx, cache, accessKey, regUser, func(stored *UserIdentity) error {
|
||||
if stored.Credentials.IsTemp() || stored.Credentials.IsServiceAccount() {
|
||||
return errIAMActionNotAllowed
|
||||
}
|
||||
return auth.AccountOff
|
||||
}(),
|
||||
stored.Credentials.Status = auth.AccountOff
|
||||
if status == madmin.AccountEnabled {
|
||||
stored.Credentials.Status = auth.AccountOn
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if err := store.saveUserIdentity(ctx, accessKey, regUser, &uinfo); err != nil {
|
||||
if errors.Is(err, errIAMIdentityGone) {
|
||||
return updatedAt, errNoSuchUser
|
||||
}
|
||||
if err != nil {
|
||||
return updatedAt, err
|
||||
}
|
||||
|
||||
if err := cache.updateUserWithClaims(accessKey, uinfo); err != nil {
|
||||
return updatedAt, err
|
||||
}
|
||||
|
||||
return uinfo.UpdatedAt, nil
|
||||
return u.UpdatedAt, nil
|
||||
}
|
||||
|
||||
// AddServiceAccount - add a new service account
|
||||
@@ -2842,10 +2830,12 @@ func (store *IAMStoreSys) AddServiceAccount(ctx context.Context, cred auth.Crede
|
||||
}
|
||||
|
||||
u := newUserIdentity(cred)
|
||||
err = store.saveUserIdentity(ctx, u.Credentials.AccessKey, svcUser, &u)
|
||||
create := &iamIdentityCreate{UserIdentity: u}
|
||||
err = store.saveIAMRevision(ctx, getUserIdentityPath(u.Credentials.AccessKey, svcUser), create)
|
||||
if err != nil {
|
||||
return updatedAt, err
|
||||
}
|
||||
u = create.UserIdentity
|
||||
|
||||
if err := cache.updateUserWithClaims(u.Credentials.AccessKey, u); err != nil {
|
||||
// Persistence already replaced the credential. A failed refresh must
|
||||
@@ -2863,15 +2853,39 @@ func (store *IAMStoreSys) UpdateServiceAccount(ctx context.Context, accessKey st
|
||||
cache := store.lock()
|
||||
defer store.unlock()
|
||||
|
||||
ui, ok := cache.iamUsersMap[accessKey]
|
||||
if !ok || !ui.Credentials.IsServiceAccount() {
|
||||
u, _, err := store.editStoredIdentity(ctx, cache, accessKey, svcUser, func(stored *UserIdentity) error {
|
||||
if !stored.Credentials.IsServiceAccount() {
|
||||
return errIAMIdentityGone
|
||||
}
|
||||
if opts.parentUser != "" && stored.Credentials.ParentUser != opts.parentUser {
|
||||
// Not the account the caller was authorized for: it was
|
||||
// deleted and created again for another parent meanwhile.
|
||||
return errIAMIdentityGone
|
||||
}
|
||||
cr, err := updatedServiceAccountCredentials(ctx, accessKey, stored.Credentials, opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stored.Credentials = cr
|
||||
return nil
|
||||
})
|
||||
if errors.Is(err, errIAMIdentityGone) {
|
||||
return updatedAt, errNoSuchServiceAccount
|
||||
}
|
||||
cr := ui.Credentials
|
||||
if err != nil {
|
||||
return updatedAt, err
|
||||
}
|
||||
return u.UpdatedAt, nil
|
||||
}
|
||||
|
||||
// updatedServiceAccountCredentials returns cr, the stored credential of
|
||||
// service account accessKey, with the edits of opts and a session token
|
||||
// signed again for them.
|
||||
func updatedServiceAccountCredentials(ctx context.Context, accessKey string, cr auth.Credentials, opts updateServiceAccountOpts) (auth.Credentials, error) {
|
||||
currentSecretKey := cr.SecretKey
|
||||
if opts.secretKey != "" {
|
||||
if !auth.IsSecretKeyValid(opts.secretKey) {
|
||||
return updatedAt, auth.ErrInvalidSecretKeyLength
|
||||
return cr, auth.ErrInvalidSecretKeyLength
|
||||
}
|
||||
cr.SecretKey = opts.secretKey
|
||||
}
|
||||
@@ -2887,7 +2901,7 @@ func (store *IAMStoreSys) UpdateServiceAccount(ctx context.Context, accessKey st
|
||||
if opts.expiration != nil {
|
||||
expirationInUTC := opts.expiration.UTC()
|
||||
if err := validateSvcExpirationInUTC(ctx, expirationInUTC); err != nil {
|
||||
return updatedAt, err
|
||||
return cr, err
|
||||
}
|
||||
cr.Expiration = expirationInUTC
|
||||
}
|
||||
@@ -2903,42 +2917,31 @@ func (store *IAMStoreSys) UpdateServiceAccount(ctx context.Context, accessKey st
|
||||
case auth.AccountOn, auth.AccountOff:
|
||||
cr.Status = opts.status
|
||||
default:
|
||||
return updatedAt, errors.New("unknown account status value")
|
||||
return cr, errors.New("unknown account status value")
|
||||
}
|
||||
|
||||
m, err := getClaimsFromTokenWithSecret(cr.SessionToken, currentSecretKey)
|
||||
if err != nil {
|
||||
return updatedAt, fmt.Errorf("unable to get svc acc claims: %v", err)
|
||||
return cr, fmt.Errorf("unable to get svc acc claims: %v", err)
|
||||
}
|
||||
|
||||
// Extracted session policy name string can be removed as its not useful
|
||||
// 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.
|
||||
if opts.sessionPolicy != nil {
|
||||
policyBuf, err := marshalServiceAccountPolicy(opts.sessionPolicy)
|
||||
if err != nil {
|
||||
return cr, err
|
||||
}
|
||||
if len(policyBuf) == 0 {
|
||||
m.Delete(policy.SessionPolicyName)
|
||||
m.Set(iamPolicyClaimNameSA(), inheritedPolicyType)
|
||||
}
|
||||
|
||||
if opts.sessionPolicy != nil { // session policies is being updated
|
||||
if err := opts.sessionPolicy.Validate(); err != nil {
|
||||
return updatedAt, err
|
||||
}
|
||||
|
||||
if opts.sessionPolicy.Version != "" && len(opts.sessionPolicy.Statements) > 0 {
|
||||
policyBuf, err := json.Marshal(opts.sessionPolicy)
|
||||
if err != nil {
|
||||
return updatedAt, err
|
||||
}
|
||||
|
||||
if len(policyBuf) > maxSVCSessionPolicySize {
|
||||
return updatedAt, errSessionPolicyTooLarge
|
||||
}
|
||||
|
||||
} else {
|
||||
// Overwrite session policy claims.
|
||||
m.Set(policy.SessionPolicyName, base64.StdEncoding.EncodeToString(policyBuf))
|
||||
m.Set(iamPolicyClaimNameSA(), embeddedPolicyType)
|
||||
@@ -2947,21 +2950,11 @@ func (store *IAMStoreSys) UpdateServiceAccount(ctx context.Context, accessKey st
|
||||
|
||||
cr.SessionToken, err = auth.JWTSignWithAccessKey(accessKey, m.Map(), cr.SecretKey)
|
||||
if err != nil {
|
||||
return updatedAt, err
|
||||
return cr, err
|
||||
}
|
||||
|
||||
u := newUserIdentity(cr)
|
||||
if err := store.saveUserIdentity(ctx, u.Credentials.AccessKey, svcUser, &u); err != nil {
|
||||
return updatedAt, err
|
||||
}
|
||||
|
||||
if err := cache.updateUserWithClaims(u.Credentials.AccessKey, u); err != nil {
|
||||
delete(cache.iamUsersMap, accessKey)
|
||||
cache.updatedAt = UTCNow()
|
||||
return updatedAt, err
|
||||
}
|
||||
|
||||
return u.UpdatedAt, nil
|
||||
// The stored claims are those of the new token; the save extracts them.
|
||||
cr.Claims = nil
|
||||
return cr, nil
|
||||
}
|
||||
|
||||
// ListTempAccounts - lists only temporary accounts from the cache.
|
||||
@@ -3105,20 +3098,14 @@ func (store *IAMStoreSys) UpdateUserSecretKey(ctx context.Context, accessKey, se
|
||||
cache := store.lock()
|
||||
defer store.unlock()
|
||||
|
||||
cache.updatedAt = time.Now()
|
||||
|
||||
ui, ok := cache.iamUsersMap[accessKey]
|
||||
if !ok {
|
||||
_, _, err := store.editStoredIdentity(ctx, cache, accessKey, regUser, func(stored *UserIdentity) error {
|
||||
stored.Credentials.SecretKey = secretKey
|
||||
return nil
|
||||
})
|
||||
if errors.Is(err, errIAMIdentityGone) {
|
||||
return errNoSuchUser
|
||||
}
|
||||
cred := ui.Credentials
|
||||
cred.SecretKey = secretKey
|
||||
u := newUserIdentity(cred)
|
||||
if err := store.saveUserIdentity(ctx, accessKey, regUser, &u); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return cache.updateUserWithClaims(accessKey, u)
|
||||
}
|
||||
|
||||
// GetSTSAndServiceAccounts - returns all STS and Service account credentials.
|
||||
@@ -3144,39 +3131,99 @@ func (store *IAMStoreSys) getSTSAndServiceAccounts(cache *iamCache) []auth.Crede
|
||||
return res
|
||||
}
|
||||
|
||||
// UpdateUserIdentity - updates a user credential.
|
||||
func (store *IAMStoreSys) UpdateUserIdentity(ctx context.Context, cred auth.Credentials) error {
|
||||
// RefreshUserGroups sets the groups of the STS or service-account
|
||||
// credential that cred was read from to groups, on the record stored now
|
||||
// (see editStoredIdentity). Nothing else of the record changes. A
|
||||
// credential that is gone, expired or now has another parent, whose groups
|
||||
// the directory lookup did not compute, is left alone in storage and
|
||||
// dropped from the cache. It reports whether it wrote.
|
||||
func (store *IAMStoreSys) RefreshUserGroups(ctx context.Context, cred auth.Credentials, groups []string) (bool, error) {
|
||||
cache := store.lock()
|
||||
defer store.unlock()
|
||||
|
||||
cache.updatedAt = time.Now()
|
||||
|
||||
userType := regUser
|
||||
if cred.IsServiceAccount() {
|
||||
userType = svcUser
|
||||
} else if cred.IsTemp() {
|
||||
userType := svcUser
|
||||
if !cred.IsServiceAccount() {
|
||||
if !cred.IsTemp() {
|
||||
return false, errInvalidArgument
|
||||
}
|
||||
userType = stsUser
|
||||
}
|
||||
ui := newUserIdentity(cred)
|
||||
// Overwrite the user identity here. As store should be
|
||||
// atomic, it shouldn't cause any corruption.
|
||||
if err := store.saveUserIdentity(ctx, cred.AccessKey, userType, &ui); err != nil {
|
||||
return err
|
||||
_, unchanged, err := store.editStoredIdentity(ctx, cache, cred.AccessKey, userType, func(stored *UserIdentity) error {
|
||||
if stored.Credentials.ParentUser != cred.ParentUser || stored.Credentials.IsExpired() {
|
||||
return errIAMIdentityGone
|
||||
}
|
||||
if set.CreateStringSet(stored.Credentials.Groups...).Equals(set.CreateStringSet(groups...)) {
|
||||
return errIAMIdentityUnchanged
|
||||
}
|
||||
stored.Credentials.Groups = groups
|
||||
return nil
|
||||
})
|
||||
if errors.Is(err, errIAMIdentityGone) || errors.Is(err, errIAMStaleUpdate) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return !unchanged, nil
|
||||
}
|
||||
|
||||
return cache.updateUserWithClaims(cred.AccessKey, ui)
|
||||
// editStoredIdentity applies edit to identity accessKey of userType as it
|
||||
// is stored now, under its revision lock (see iamIdentityEdit), and caches
|
||||
// the result. The caller holds the cache lock. When the identity is absent
|
||||
// or deleted in storage, or edit returns errIAMIdentityGone because it is
|
||||
// no longer the identity the edit addressed, errIAMIdentityGone is
|
||||
// returned; a replicated edit older than what is stored returns
|
||||
// errIAMStaleUpdate. Either way storage holds something else than the
|
||||
// cached copy, which is dropped, so a lookup loads what is stored.
|
||||
func (store *IAMStoreSys) editStoredIdentity(ctx context.Context, cache *iamCache, accessKey string, userType IAMUserType, edit func(u *UserIdentity) error) (UserIdentity, bool, error) {
|
||||
op := &iamIdentityEdit{accessKey: accessKey, edit: edit}
|
||||
err := store.saveIAMRevision(ctx, getUserIdentityPath(accessKey, userType), op)
|
||||
if errors.Is(err, errIAMIdentityGone) || errors.Is(err, errIAMStaleUpdate) {
|
||||
// Storage holds something else than the cached copy: drop it.
|
||||
store.forgetCachedIdentity(cache, accessKey, userType)
|
||||
}
|
||||
if err != nil {
|
||||
return UserIdentity{}, false, err
|
||||
}
|
||||
if err := cache.updateUserWithClaims(accessKey, op.UserIdentity); err != nil {
|
||||
// Stored but not cached: a lookup loads it.
|
||||
store.forgetCachedIdentity(cache, accessKey, userType)
|
||||
return UserIdentity{}, false, err
|
||||
}
|
||||
return op.UserIdentity, op.unchanged, nil
|
||||
}
|
||||
|
||||
// forgetCachedIdentity drops identity accessKey of userType from the cache;
|
||||
// a lookup then loads it from storage. Users and service accounts share a
|
||||
// map, so an entry of the other kind under the same key is kept.
|
||||
func (store *IAMStoreSys) forgetCachedIdentity(cache *iamCache, accessKey string, userType IAMUserType) {
|
||||
switch userType {
|
||||
case stsUser:
|
||||
delete(cache.iamSTSAccountsMap, accessKey)
|
||||
default:
|
||||
if ui, ok := cache.iamUsersMap[accessKey]; ok && ui.Credentials.IsServiceAccount() == (userType == svcUser) {
|
||||
delete(cache.iamUsersMap, accessKey)
|
||||
}
|
||||
}
|
||||
cache.updatedAt = time.Now()
|
||||
if store.group != nil {
|
||||
store.group.Forget(accessKey)
|
||||
}
|
||||
}
|
||||
|
||||
// LoadUser - attempts to load user info from storage and updates cache.
|
||||
func (store *IAMStoreSys) LoadUser(ctx context.Context, accessKey string) error {
|
||||
groupLoad := env.Get("_MINIO_IAM_GROUP_REFRESH", config.EnableOff) == config.EnableOn
|
||||
|
||||
newCachePopulate := func() (val any, err error) {
|
||||
newCache := newIamCache()
|
||||
newCachePopulate := func(ctx context.Context) (newCache *iamCache, err error) {
|
||||
newCache = newIamCache()
|
||||
newCache.revisions = store.revisionIndex()
|
||||
|
||||
// Check for service account first
|
||||
store.loadUser(ctx, accessKey, svcUser, newCache.iamUsersMap)
|
||||
err = store.loadUser(ctx, accessKey, svcUser, newCache.iamUsersMap)
|
||||
if err != nil && !errors.Is(err, errNoSuchUser) && !errors.Is(err, errConfigNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
svc, found := newCache.iamUsersMap[accessKey]
|
||||
if found {
|
||||
@@ -3194,6 +3241,9 @@ func (store *IAMStoreSys) LoadUser(ctx context.Context, accessKey string) error
|
||||
|
||||
if !found {
|
||||
err = store.loadUser(ctx, accessKey, regUser, newCache.iamUsersMap)
|
||||
if err != nil && !errors.Is(err, errNoSuchUser) && !errors.Is(err, errConfigNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
if _, found = newCache.iamUsersMap[accessKey]; found {
|
||||
// NOTE: we are not worried about loading errors from policies.
|
||||
store.loadMappedPolicyWithRetry(ctx, accessKey, regUser, false, newCache.iamUserPolicyMap, 3)
|
||||
@@ -3237,32 +3287,26 @@ func (store *IAMStoreSys) LoadUser(ctx context.Context, accessKey string) error
|
||||
return newCache, err
|
||||
}
|
||||
|
||||
var (
|
||||
val any
|
||||
err error
|
||||
)
|
||||
if store.group != nil {
|
||||
val, err, _ = store.group.Do(accessKey, newCachePopulate)
|
||||
} else {
|
||||
val, err = newCachePopulate()
|
||||
}
|
||||
|
||||
// Return error right away if any.
|
||||
load := func() (any, error) {
|
||||
loadedAt := time.Now()
|
||||
newCache, err := newCachePopulate(ctx)
|
||||
if err != nil {
|
||||
if errors.Is(err, errNoSuchUser) || errors.Is(err, errConfigNotFound) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
newCache, ok := val.(*iamCache)
|
||||
if !ok {
|
||||
return nil
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cache := store.lock()
|
||||
defer store.unlock()
|
||||
|
||||
if cache.updatedAt.After(loadedAt) {
|
||||
err = store.withIAMStorage(ctx, func(ctx context.Context) error {
|
||||
newCache, err = newCachePopulate(ctx)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// We need to merge the new cache with the existing cache because the
|
||||
// periodic IAM reload is partial. The periodic load here is to account.
|
||||
newCache.iamGroupPolicyMap.Range(func(k string, v MappedPolicy) bool {
|
||||
@@ -3291,8 +3335,19 @@ func (store *IAMStoreSys) LoadUser(ctx context.Context, accessKey string) error
|
||||
})
|
||||
|
||||
cache.updatedAt = time.Now()
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var err error
|
||||
if store.group != nil {
|
||||
_, err, _ = store.group.Do(accessKey, load)
|
||||
} else {
|
||||
_, err = load()
|
||||
}
|
||||
if errors.Is(err, errNoSuchUser) || errors.Is(err, errConfigNotFound) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
var errTokenSigningKeyUnavailable = errors.New("token signing key unavailable")
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
// Copyright (c) 2026 PGSTY
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/minio/madmin-go/v3"
|
||||
"github.com/minio/minio/internal/auth"
|
||||
"github.com/pgsty/silo-pkg/v3/policy"
|
||||
)
|
||||
|
||||
// An administrator edits an identity through a server whose cache missed
|
||||
// a rotation or a deletion made through another. The edit applies to what
|
||||
// is stored and must not write the stale copy back.
|
||||
|
||||
func TestIAMStaleServiceAccountEdit(t *testing.T) {
|
||||
t.Run("rotated", func(t *testing.T) {
|
||||
f := newGroupRefreshFixture(t)
|
||||
snapshot := f.service(t, "edit-rotated")
|
||||
other := f.sibling(t)
|
||||
_, err := f.sys.UpdateServiceAccount(f.ctx, snapshot.AccessKey, updateServiceAccountOpts{secretKey: "edit-rotated-secret"})
|
||||
mustIAM(t, err)
|
||||
if cached, _ := other.GetUser(snapshot.AccessKey); cached.Credentials.SecretKey != snapshot.SecretKey {
|
||||
t.Fatal("control: the stale server must not have heard of the rotation")
|
||||
}
|
||||
_, err = other.UpdateServiceAccount(f.ctx, snapshot.AccessKey, updateServiceAccountOpts{description: "edited on a stale server"})
|
||||
mustIAM(t, err)
|
||||
for where, get := range map[string]func() (UserIdentity, bool){
|
||||
"storage": func() (UserIdentity, bool) { return f.stored(t, snapshot.AccessKey) },
|
||||
"stale server": func() (UserIdentity, bool) { return other.GetUser(snapshot.AccessKey) },
|
||||
} {
|
||||
u, ok := get()
|
||||
if !ok || u.Credentials.SecretKey != "edit-rotated-secret" || u.Credentials.Description != "edited on a stale server" {
|
||||
t.Errorf("%s: present=%v rotated=%v description=%q: the edit must apply to the rotated credential",
|
||||
where, ok, u.Credentials.SecretKey == "edit-rotated-secret", u.Credentials.Description)
|
||||
}
|
||||
}
|
||||
})
|
||||
t.Run("deleted", func(t *testing.T) {
|
||||
f := newGroupRefreshFixture(t)
|
||||
snapshot := f.service(t, "edit-deleted")
|
||||
other := f.sibling(t)
|
||||
mustIAM(t, f.sys.DeleteServiceAccount(f.ctx, snapshot.AccessKey, false))
|
||||
_, err := other.UpdateServiceAccount(f.ctx, snapshot.AccessKey, updateServiceAccountOpts{description: "edited on a stale server"})
|
||||
if !errors.Is(err, errNoSuchServiceAccount) {
|
||||
t.Errorf("an edit of a deleted account must fail with no such service account, got %v", err)
|
||||
}
|
||||
if _, ok := f.stored(t, snapshot.AccessKey); ok {
|
||||
t.Error("an edit on a stale server brought a deleted account back")
|
||||
}
|
||||
if _, ok := other.GetUser(snapshot.AccessKey); ok {
|
||||
t.Error("the stale server still caches the deleted account")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// A server that missed the create notification must not replace a live
|
||||
// service account merely because its local cache has no entry for the key.
|
||||
func TestIAMStaleServiceAccountCreate(t *testing.T) {
|
||||
f := newGroupRefreshFixture(t)
|
||||
other := f.sibling(t)
|
||||
original := f.service(t, "create-stale")
|
||||
if _, ok := other.GetUser(original.AccessKey); ok {
|
||||
t.Fatal("control: the stale server must not have loaded the new account")
|
||||
}
|
||||
|
||||
claims := map[string]any{
|
||||
parentClaim: f.parent.AccessKey,
|
||||
iamPolicyClaimNameSA(): inheritedPolicyType,
|
||||
}
|
||||
mustIAM(t, setIAMParentRevocationClaim(f.ctx, other, f.parent.AccessKey, claims))
|
||||
replacement, err := auth.CreateNewCredentialsWithMetadata(original.AccessKey, "create-replacement-secret", claims, "create-replacement-secret")
|
||||
mustIAM(t, err)
|
||||
replacement.ParentUser = f.parent.AccessKey
|
||||
replacement.Status = auth.AccountOn
|
||||
if _, err := other.AddServiceAccount(f.ctx, replacement); !errors.Is(err, errIAMServiceAccountNotAllowed) {
|
||||
t.Fatalf("stale create should be rejected, got %v", err)
|
||||
}
|
||||
|
||||
stored, ok := f.stored(t, original.AccessKey)
|
||||
if !ok || stored.Credentials.SecretKey != original.SecretKey || stored.Credentials.ParentUser != original.ParentUser {
|
||||
t.Fatalf("stale create changed the live account: present=%v parent=%q secret=%q", ok, stored.Credentials.ParentUser, stored.Credentials.SecretKey)
|
||||
}
|
||||
}
|
||||
|
||||
// A session policy edit stores the claims of the token it signs.
|
||||
func TestIAMServiceAccountEditStoresNewClaims(t *testing.T) {
|
||||
f := newGroupRefreshFixture(t)
|
||||
snapshot := f.service(t, "edit-claims")
|
||||
sp, err := policy.ParseConfig(strings.NewReader(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:GetObject"],"Resource":["arn:aws:s3:::edit/*"]}]}`))
|
||||
mustIAM(t, err)
|
||||
_, err = f.sys.UpdateServiceAccount(f.ctx, snapshot.AccessKey, updateServiceAccountOpts{sessionPolicy: sp})
|
||||
mustIAM(t, err)
|
||||
var raw UserIdentity
|
||||
mustIAM(t, f.sys.store.loadIAMConfig(f.ctx, &raw, getUserIdentityPath(snapshot.AccessKey, svcUser)))
|
||||
claims, err := getClaimsFromTokenWithSecret(raw.Credentials.SessionToken, raw.Credentials.SecretKey)
|
||||
mustIAM(t, err)
|
||||
if raw.Credentials.Claims[iamPolicyClaimNameSA()] != embeddedPolicyType || claims.MapClaims[iamPolicyClaimNameSA()] != embeddedPolicyType {
|
||||
t.Errorf("stored claims %v, token claims %v: both must name the embedded policy", raw.Credentials.Claims[iamPolicyClaimNameSA()], claims.MapClaims[iamPolicyClaimNameSA()])
|
||||
}
|
||||
}
|
||||
|
||||
func TestIAMStaleUserEdit(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
change func(t *testing.T, f *groupRefreshFixture, user auth.Credentials)
|
||||
edit func(s *IAMStoreSys, f *groupRefreshFixture, user auth.Credentials) error
|
||||
check func(t *testing.T, u UserIdentity, ok bool, err error)
|
||||
}{
|
||||
{"status-after-delete", func(t *testing.T, f *groupRefreshFixture, user auth.Credentials) {
|
||||
mustIAM(t, f.sys.DeleteUser(f.ctx, user.AccessKey, false))
|
||||
}, func(s *IAMStoreSys, f *groupRefreshFixture, user auth.Credentials) error {
|
||||
_, err := s.SetUserStatus(f.ctx, user.AccessKey, madmin.AccountEnabled)
|
||||
return err
|
||||
}, func(t *testing.T, u UserIdentity, ok bool, err error) {
|
||||
if !errors.Is(err, errNoSuchUser) || ok {
|
||||
t.Errorf("err=%v present=%v: a status change on a stale server must not bring a deleted user back", err, ok)
|
||||
}
|
||||
}},
|
||||
{"status-after-rotation", func(t *testing.T, f *groupRefreshFixture, user auth.Credentials) {
|
||||
mustIAM(t, f.sys.SetUserSecretKey(f.ctx, user.AccessKey, "edit-user-rotated"))
|
||||
}, func(s *IAMStoreSys, f *groupRefreshFixture, user auth.Credentials) error {
|
||||
_, err := s.SetUserStatus(f.ctx, user.AccessKey, madmin.AccountDisabled)
|
||||
return err
|
||||
}, func(t *testing.T, u UserIdentity, ok bool, err error) {
|
||||
if err != nil || !ok || u.Credentials.SecretKey != "edit-user-rotated" || u.Credentials.Status != auth.AccountOff {
|
||||
t.Errorf("err=%v present=%v rotated=%v status=%q: the status change must apply to the rotated user",
|
||||
err, ok, u.Credentials.SecretKey == "edit-user-rotated", u.Credentials.Status)
|
||||
}
|
||||
}},
|
||||
{"secret-after-delete", func(t *testing.T, f *groupRefreshFixture, user auth.Credentials) {
|
||||
mustIAM(t, f.sys.DeleteUser(f.ctx, user.AccessKey, false))
|
||||
}, func(s *IAMStoreSys, f *groupRefreshFixture, user auth.Credentials) error {
|
||||
return s.UpdateUserSecretKey(f.ctx, user.AccessKey, "edit-user-new-secret")
|
||||
}, func(t *testing.T, u UserIdentity, ok bool, err error) {
|
||||
if !errors.Is(err, errNoSuchUser) || ok {
|
||||
t.Errorf("err=%v present=%v: a secret change on a stale server must not bring a deleted user back", err, ok)
|
||||
}
|
||||
}},
|
||||
{"secret-after-disable", func(t *testing.T, f *groupRefreshFixture, user auth.Credentials) {
|
||||
_, err := f.sys.SetUserStatus(f.ctx, user.AccessKey, madmin.AccountDisabled)
|
||||
mustIAM(t, err)
|
||||
}, func(s *IAMStoreSys, f *groupRefreshFixture, user auth.Credentials) error {
|
||||
return s.UpdateUserSecretKey(f.ctx, user.AccessKey, "edit-user-new-secret")
|
||||
}, func(t *testing.T, u UserIdentity, ok bool, err error) {
|
||||
if err != nil || !ok || u.Credentials.SecretKey != "edit-user-new-secret" || u.Credentials.Status != auth.AccountOff {
|
||||
t.Errorf("err=%v present=%v status=%q: a secret change on a stale server must keep the user disabled", err, ok, u.Credentials.Status)
|
||||
}
|
||||
}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
f := newGroupRefreshFixture(t)
|
||||
user := iamAdminUser(f.ctx, t, f.sys, "edit-user", "refresh-parent")
|
||||
other := f.sibling(t)
|
||||
tc.change(t, f, user)
|
||||
if cached, ok := other.GetUser(user.AccessKey); !ok || cached.Credentials.SecretKey != user.SecretKey {
|
||||
t.Fatal("control: the stale server must not have heard of the change")
|
||||
}
|
||||
err := tc.edit(other, f, user)
|
||||
u, ok := f.stored(t, user.AccessKey)
|
||||
tc.check(t, u, ok, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// An edit that the caller was authorized for under one parent does not
|
||||
// apply to an account stored under another parent: the account was deleted
|
||||
// and created again for it meanwhile.
|
||||
func TestIAMServiceAccountEditKeepsAuthorizedParent(t *testing.T) {
|
||||
f := newGroupRefreshFixture(t)
|
||||
snapshot := f.service(t, "edit-parent")
|
||||
other := iamAdminUser(f.ctx, t, f.sys, "edit-other-parent", "refresh-parent")
|
||||
mustIAM(t, f.sys.DeleteServiceAccount(f.ctx, snapshot.AccessKey, false))
|
||||
_, _, err := f.sys.NewServiceAccount(f.ctx, other.AccessKey, nil, newServiceAccountOpts{accessKey: snapshot.AccessKey, secretKey: "edit-other-secret"})
|
||||
mustIAM(t, err)
|
||||
_, err = f.sys.store.UpdateServiceAccount(f.ctx, snapshot.AccessKey, updateServiceAccountOpts{secretKey: "edit-chosen-secret", parentUser: snapshot.ParentUser})
|
||||
if !errors.Is(err, errNoSuchServiceAccount) {
|
||||
t.Errorf("an edit authorized for another parent must fail, got %v", err)
|
||||
}
|
||||
if u, ok := f.stored(t, snapshot.AccessKey); !ok || u.Credentials.SecretKey != "edit-other-secret" {
|
||||
t.Errorf("present=%v: the account of the other parent must keep its secret", ok)
|
||||
}
|
||||
// Without a bound parent (replication), the edit applies.
|
||||
_, err = f.sys.store.UpdateServiceAccount(f.ctx, snapshot.AccessKey, updateServiceAccountOpts{description: "replicated"})
|
||||
mustIAM(t, err)
|
||||
}
|
||||
|
||||
// A record stored without claims, or with only a deprecated comment, is
|
||||
// edited as the loaders present it.
|
||||
func TestIAMServiceAccountEditLegacyRecord(t *testing.T) {
|
||||
f := newGroupRefreshFixture(t)
|
||||
snapshot := f.service(t, "edit-legacy")
|
||||
path := getUserIdentityPath(snapshot.AccessKey, svcUser)
|
||||
var raw UserIdentity
|
||||
mustIAM(t, f.sys.store.loadIAMConfig(f.ctx, &raw, path))
|
||||
raw.Credentials.Claims = nil
|
||||
raw.Credentials.Description, raw.Credentials.Comment = "", "legacy comment"
|
||||
mustIAM(t, f.sys.store.saveIAMConfig(f.ctx, &raw, path))
|
||||
_, err := f.sys.store.UpdateServiceAccount(f.ctx, snapshot.AccessKey, updateServiceAccountOpts{status: string(madmin.AccountDisabled)})
|
||||
mustIAM(t, err)
|
||||
for where, get := range map[string]func() (UserIdentity, bool){
|
||||
"storage": func() (UserIdentity, bool) { return f.stored(t, snapshot.AccessKey) },
|
||||
"cache": func() (UserIdentity, bool) { return f.sys.store.GetUser(snapshot.AccessKey) },
|
||||
} {
|
||||
u, ok := get()
|
||||
if !ok || u.Credentials.Status != auth.AccountOff || u.Credentials.Description != "legacy comment" {
|
||||
t.Errorf("%s: present=%v status=%q description=%q", where, ok, u.Credentials.Status, u.Credentials.Description)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A peer's edit from before a deletion this site applied is acknowledged as
|
||||
// stale; one from after it is not found.
|
||||
func TestIAMReplicatedEditAfterDeletion(t *testing.T) {
|
||||
f := newGroupRefreshFixture(t)
|
||||
snapshot := f.service(t, "edit-repl")
|
||||
user := iamAdminUser(f.ctx, t, f.sys, "edit-repl-user", "refresh-parent")
|
||||
before := UTCNow()
|
||||
mustIAM(t, f.sys.DeleteServiceAccount(f.ctx, snapshot.AccessKey, false))
|
||||
mustIAM(t, f.sys.DeleteUser(f.ctx, user.AccessKey, false))
|
||||
after := UTCNow().Add(time.Second)
|
||||
|
||||
_, err := f.sys.store.UpdateServiceAccount(withIAMReplicationTime(f.ctx, before), snapshot.AccessKey, updateServiceAccountOpts{description: "late"})
|
||||
if iamReplicationError(err) != nil {
|
||||
t.Errorf("an older service-account edit must be acknowledged, got %v", err)
|
||||
}
|
||||
_, err = f.sys.store.SetUserStatus(withIAMReplicationTime(f.ctx, before), user.AccessKey, madmin.AccountDisabled)
|
||||
if iamReplicationError(err) != nil {
|
||||
t.Errorf("an older status edit must be acknowledged, got %v", err)
|
||||
}
|
||||
if _, err = f.sys.store.UpdateServiceAccount(withIAMReplicationTime(f.ctx, after), snapshot.AccessKey, updateServiceAccountOpts{description: "late"}); !errors.Is(err, errNoSuchServiceAccount) {
|
||||
t.Errorf("a newer edit of a deleted account must not be acknowledged as stale, got %v", err)
|
||||
}
|
||||
if _, ok := f.stored(t, snapshot.AccessKey); ok {
|
||||
t.Error("a replicated edit brought a deleted account back")
|
||||
}
|
||||
}
|
||||
|
||||
// A status change addressed to a service account is refused as before, and
|
||||
// the account stays cached.
|
||||
func TestIAMUserStatusOnServiceAccount(t *testing.T) {
|
||||
f := newGroupRefreshFixture(t)
|
||||
snapshot := f.service(t, "edit-sa-status")
|
||||
_, err := f.sys.store.SetUserStatus(f.ctx, snapshot.AccessKey, madmin.AccountDisabled)
|
||||
if !errors.Is(err, errIAMActionNotAllowed) {
|
||||
t.Errorf("got %v, want errIAMActionNotAllowed", err)
|
||||
}
|
||||
if _, ok := f.sys.store.GetUser(snapshot.AccessKey); !ok {
|
||||
t.Error("a refused status change dropped the service account from the cache")
|
||||
}
|
||||
if err := f.sys.store.UpdateUserSecretKey(f.ctx, snapshot.AccessKey, "edit-not-a-user"); !errors.Is(err, errNoSuchUser) {
|
||||
t.Errorf("a user secret change of a service account must find no user, got %v", err)
|
||||
}
|
||||
if u, ok := f.sys.store.GetUser(snapshot.AccessKey); !ok || u.Credentials.SecretKey != snapshot.SecretKey {
|
||||
t.Error("a user secret change of a service account touched the account")
|
||||
}
|
||||
}
|
||||
|
||||
// A server whose cache still holds the account of the first parent drops
|
||||
// it when its edit is refused, whether the edit was bound to that parent or
|
||||
// replicated from before the deletion: storage says the cached copy is gone.
|
||||
func TestIAMRefusedEditDropsStaleCache(t *testing.T) {
|
||||
t.Run("rebound-parent", func(t *testing.T) {
|
||||
f := newGroupRefreshFixture(t)
|
||||
snapshot := f.service(t, "edit-rebind")
|
||||
other := f.sibling(t)
|
||||
next := iamAdminUser(f.ctx, t, f.sys, "edit-rebind-next", "refresh-parent")
|
||||
mustIAM(t, f.sys.DeleteServiceAccount(f.ctx, snapshot.AccessKey, false))
|
||||
_, _, err := f.sys.NewServiceAccount(f.ctx, next.AccessKey, nil, newServiceAccountOpts{accessKey: snapshot.AccessKey, secretKey: "edit-rebind-secret"})
|
||||
mustIAM(t, err)
|
||||
if cached, ok := other.GetUser(snapshot.AccessKey); !ok || cached.Credentials.ParentUser != snapshot.ParentUser {
|
||||
t.Fatal("control: the stale server must still cache the first account")
|
||||
}
|
||||
_, err = other.UpdateServiceAccount(f.ctx, snapshot.AccessKey, updateServiceAccountOpts{description: "stale", parentUser: snapshot.ParentUser})
|
||||
if !errors.Is(err, errNoSuchServiceAccount) {
|
||||
t.Errorf("got %v, want errNoSuchServiceAccount", err)
|
||||
}
|
||||
if _, ok := other.GetUser(snapshot.AccessKey); ok {
|
||||
t.Error("the stale server still caches the account of the first parent")
|
||||
}
|
||||
})
|
||||
t.Run("replicated-stale", func(t *testing.T) {
|
||||
f := newGroupRefreshFixture(t)
|
||||
snapshot := f.service(t, "edit-repl-stale")
|
||||
other := f.sibling(t)
|
||||
before := UTCNow()
|
||||
mustIAM(t, f.sys.DeleteServiceAccount(f.ctx, snapshot.AccessKey, false))
|
||||
_, err := other.UpdateServiceAccount(withIAMReplicationTime(f.ctx, before), snapshot.AccessKey, updateServiceAccountOpts{description: "late"})
|
||||
if iamReplicationError(err) != nil {
|
||||
t.Errorf("an older replicated edit must be acknowledged, got %v", err)
|
||||
}
|
||||
if _, ok := other.GetUser(snapshot.AccessKey); ok {
|
||||
t.Error("the server that acknowledged a stale edit still caches the deleted account")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
// Copyright (c) 2026 PGSTY
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"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 TestIAMServiceAccountPolicySemantics(t *testing.T) {
|
||||
for _, expiring := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("expiring=%v", expiring), func(t *testing.T) {
|
||||
f := newGroupRefreshFixture(t)
|
||||
readPolicy, err := policy.ParseConfig(strings.NewReader(svcAccEditPolicy))
|
||||
mustIAM(t, err)
|
||||
writePolicy, err := policy.ParseConfig(strings.NewReader(strings.ReplaceAll(svcAccEditPolicy, "s3:GetObject", "s3:PutObject")))
|
||||
mustIAM(t, err)
|
||||
_, err = f.sys.PolicyDBSet(f.ctx, f.parent.AccessKey, "readwrite", regUser, false)
|
||||
mustIAM(t, err)
|
||||
for _, action := range []policy.Action{policy.GetObjectAction, policy.PutObjectAction} {
|
||||
if !f.sys.IsAllowed(policy.Args{AccountName: f.parent.AccessKey, Action: action, BucketName: "edit", ObjectName: "object"}) {
|
||||
t.Fatal("control: the parent must allow reads and writes to the test object")
|
||||
}
|
||||
}
|
||||
noVersion, invalidVersion, oversized := *writePolicy, *writePolicy, *writePolicy
|
||||
noVersion.Version = ""
|
||||
invalidVersion.Version = "2008-10-17"
|
||||
oversized.ID = policy.ID(strings.Repeat("x", maxSVCSessionPolicySize))
|
||||
var expiration *time.Time
|
||||
if expiring {
|
||||
later := UTCNow().Add(time.Hour)
|
||||
expiration = &later
|
||||
}
|
||||
for i, tc := range []struct {
|
||||
name string
|
||||
input *policy.Policy
|
||||
invalid bool
|
||||
}{
|
||||
{"nil", nil, false},
|
||||
{"empty", &policy.Policy{}, false},
|
||||
{"empty-statements", &policy.Policy{Statements: []policy.Statement{}}, false},
|
||||
{"empty-with-version", &policy.Policy{Version: policy.DefaultVersion, Statements: []policy.Statement{}}, false},
|
||||
{"missing-version", &noVersion, false},
|
||||
{"version", writePolicy, false},
|
||||
{"invalid-version", &invalidVersion, true},
|
||||
{"empty-invalid-version", &policy.Policy{Version: "2008-10-17"}, true},
|
||||
{"invalid-statement", &policy.Policy{Statements: []policy.Statement{{}}}, true},
|
||||
{"oversized", &oversized, true},
|
||||
} {
|
||||
for _, edit := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("%s/edit=%v", tc.name, edit), func(t *testing.T) {
|
||||
key := fmt.Sprintf("policy-%d-%v", i, edit)
|
||||
inputBefore, err := json.Marshal(tc.input)
|
||||
mustIAM(t, err)
|
||||
var before UserIdentity
|
||||
if edit {
|
||||
_, _, err = f.sys.NewServiceAccount(f.ctx, f.parent.AccessKey, nil, newServiceAccountOpts{
|
||||
accessKey: key, secretKey: "policy-valid-secret", sessionPolicy: readPolicy, expiration: expiration,
|
||||
})
|
||||
mustIAM(t, err)
|
||||
mustIAM(t, f.sys.store.loadIAMConfig(f.ctx, &before, getUserIdentityPath(key, svcUser)))
|
||||
_, err = f.sys.UpdateServiceAccount(f.ctx, key, updateServiceAccountOpts{sessionPolicy: tc.input, description: "edited"})
|
||||
} else {
|
||||
// Snapshots can carry a stale copy of the policy in claims.
|
||||
var claims map[string]any
|
||||
if tc.input != nil {
|
||||
claims = map[string]any{policy.SessionPolicyName: base64.StdEncoding.EncodeToString([]byte(svcAccEditPolicy))}
|
||||
}
|
||||
_, _, err = f.sys.NewServiceAccount(f.ctx, f.parent.AccessKey, nil, newServiceAccountOpts{
|
||||
accessKey: key, secretKey: "policy-valid-secret", sessionPolicy: tc.input, expiration: expiration,
|
||||
claims: claims,
|
||||
})
|
||||
}
|
||||
if (err != nil) != tc.invalid {
|
||||
t.Fatalf("operation error = %v, want rejected=%v", err, tc.invalid)
|
||||
}
|
||||
inputAfter, err := json.Marshal(tc.input)
|
||||
mustIAM(t, err)
|
||||
if !bytes.Equal(inputBefore, inputAfter) {
|
||||
t.Fatal("operation changed the caller's policy")
|
||||
}
|
||||
var stored UserIdentity
|
||||
loadErr := f.sys.store.loadIAMConfig(f.ctx, &stored, getUserIdentityPath(key, svcUser))
|
||||
if tc.invalid {
|
||||
if edit {
|
||||
mustIAM(t, loadErr)
|
||||
if !reflect.DeepEqual(stored, before) {
|
||||
t.Fatal("rejected edit changed the stored account")
|
||||
}
|
||||
} else if loadErr != errConfigNotFound {
|
||||
t.Fatalf("rejected create left an account: %v", loadErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
mustIAM(t, loadErr)
|
||||
claims, err := auth.ExtractClaims(stored.Credentials.SessionToken, stored.Credentials.SecretKey)
|
||||
mustIAM(t, err)
|
||||
if _, ok := claims.Map()[sessionPolicyNameExtracted]; ok {
|
||||
t.Fatal("raw JWT contains the derived policy claim")
|
||||
}
|
||||
var want *policy.Policy
|
||||
if tc.input != nil && !tc.input.IsEmpty() {
|
||||
want = writePolicy
|
||||
} else if tc.input == nil && edit {
|
||||
want = readPolicy
|
||||
}
|
||||
got, err := embeddedServiceAccountPolicy(claims)
|
||||
mustIAM(t, err)
|
||||
if (got == nil) != (want == nil) || (got != nil && !got.Equals(*want)) {
|
||||
t.Fatalf("stored policy = %+v, want %+v", got, want)
|
||||
}
|
||||
if want == nil {
|
||||
if _, ok := claims.Lookup(policy.SessionPolicyName); ok || claims.Map()[iamPolicyClaimNameSA()] != inheritedPolicyType {
|
||||
t.Fatal("inheritance must remove the embedded policy claim")
|
||||
}
|
||||
}
|
||||
mustIAM(t, f.sys.store.LoadIAMCache(f.ctx, false))
|
||||
u, valid, err := f.sys.CheckKey(f.ctx, key)
|
||||
mustIAM(t, err)
|
||||
if !valid || u.Credentials.IsTemp() != expiring {
|
||||
t.Fatal("credential validity or expiration changed")
|
||||
}
|
||||
for _, action := range []policy.Action{policy.GetObjectAction, policy.PutObjectAction} {
|
||||
allowed := f.sys.IsAllowed(policy.Args{AccountName: key, Claims: u.Credentials.Claims, Action: action, BucketName: "edit", ObjectName: "object"})
|
||||
wantAllowed := want == nil || (want == readPolicy && action == policy.GetObjectAction) || (want == writePolicy && action == policy.PutObjectAction)
|
||||
if allowed != wantAllowed {
|
||||
t.Errorf("%s allowed=%v, want %v", action, allowed, wantAllowed)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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},
|
||||
{"peer-empty-v", madmin.SRSvcAccUpdate{SessionPolicy: madmin.SRSessionPolicy(`{"Version":"2012-10-17","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)
|
||||
}
|
||||
@@ -0,0 +1,530 @@
|
||||
// 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"
|
||||
"reflect"
|
||||
"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
|
||||
rawClaims, err := auth.ExtractClaims(cr.SessionToken, cr.SecretKey)
|
||||
mustIAM(t, err)
|
||||
if _, ok := rawClaims.Map()[sessionPolicyNameExtracted]; ok {
|
||||
t.Fatal("stored JWT contains the derived policy claim")
|
||||
}
|
||||
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 create := received.SvcAccChange.Create; create != nil {
|
||||
if _, ok := create.Claims[sessionPolicyNameExtracted]; ok {
|
||||
t.Fatal("replication snapshot contains the derived policy claim")
|
||||
}
|
||||
}
|
||||
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-removed-with-version", []madmin.UpdateServiceAccountReq{
|
||||
{NewPolicy: json.RawMessage(svcAccSnapshotPolicy)},
|
||||
{NewPolicy: json.RawMessage(`{"Version":"2012-10-17","Statement":[]}`), NewName: "named"},
|
||||
{NewDescription: "described"},
|
||||
}, func(r svcAccRecord) bool {
|
||||
return r.Policy == "" && r.Name == "named" && r.Description == "described"
|
||||
}},
|
||||
{"policy-missing-version", []madmin.UpdateServiceAccountReq{
|
||||
{NewPolicy: json.RawMessage(svcAccSnapshotPolicy)},
|
||||
{NewPolicy: json.RawMessage(strings.ReplaceAll(strings.ReplaceAll(svcAccSnapshotPolicy, `"Version":"2012-10-17",`, ""), "s3:GetObject", "s3:PutObject"))},
|
||||
{NewDescription: "described"},
|
||||
}, func(r svcAccRecord) bool {
|
||||
return r.Policy == svcAccPolicyText(t, []byte(strings.ReplaceAll(svcAccSnapshotPolicy, "s3:GetObject", "s3:PutObject"))) && 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)
|
||||
}
|
||||
if _, ok := a.Claims[sessionPolicyNameExtracted]; ok {
|
||||
t.Error("exported claims contain the derived policy claim")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestIAMServiceAccountSnapshotClaims(t *testing.T) {
|
||||
f := newGroupRefreshFixture(t)
|
||||
sp, err := policy.ParseConfig(strings.NewReader(svcAccSnapshotPolicy))
|
||||
mustIAM(t, err)
|
||||
_, _, err = f.sys.NewServiceAccount(f.ctx, f.parent.AccessKey, nil, newServiceAccountOpts{
|
||||
accessKey: "snapshot-claims", secretKey: "snapshot-valid-secret", sessionPolicy: sp,
|
||||
claims: map[string]any{"identity-attribute": "preserved"},
|
||||
})
|
||||
mustIAM(t, err)
|
||||
u, ok := f.stored(t, "snapshot-claims")
|
||||
if !ok {
|
||||
t.Fatal("service account missing")
|
||||
}
|
||||
cr := u.Credentials
|
||||
rawClaims, err := auth.ExtractClaims(cr.SessionToken, cr.SecretKey)
|
||||
mustIAM(t, err)
|
||||
derived, ok := cr.Claims[sessionPolicyNameExtracted]
|
||||
if !ok {
|
||||
t.Fatal("control: loaded claims must contain the derived policy")
|
||||
}
|
||||
snapshot, err := serviceAccountSnapshot(u)
|
||||
mustIAM(t, err)
|
||||
if !reflect.DeepEqual(snapshot.Claims, rawClaims.Map()) {
|
||||
t.Fatal("snapshot claims differ from the raw JWT")
|
||||
}
|
||||
if u.Credentials.SessionToken != cr.SessionToken || u.Credentials.Claims[sessionPolicyNameExtracted] != derived {
|
||||
t.Fatal("snapshot changed the source token or its cached claims")
|
||||
}
|
||||
}
|
||||
|
||||
// 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))
|
||||
}
|
||||
}
|
||||
+94
-42
@@ -1098,6 +1098,32 @@ type newServiceAccountOpts struct {
|
||||
claims map[string]any
|
||||
}
|
||||
|
||||
// marshalServiceAccountPolicy validates a supplied policy and normalizes its
|
||||
// version without changing the caller's policy. An empty policy means inheritance.
|
||||
func marshalServiceAccountPolicy(sp *policy.Policy) ([]byte, error) {
|
||||
if sp == nil {
|
||||
return nil, nil
|
||||
}
|
||||
p := *sp
|
||||
if p.Version == "" {
|
||||
p.Version = policy.DefaultVersion
|
||||
}
|
||||
if err := p.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if p.IsEmpty() {
|
||||
return nil, nil
|
||||
}
|
||||
buf, err := json.Marshal(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(buf) > maxSVCSessionPolicySize {
|
||||
return nil, errSessionPolicyTooLarge
|
||||
}
|
||||
return buf, nil
|
||||
}
|
||||
|
||||
// NewServiceAccount - create a new service account
|
||||
func (sys *IAMSys) NewServiceAccount(ctx context.Context, parentUser string, groups []string, opts newServiceAccountOpts) (auth.Credentials, time.Time, error) {
|
||||
if !sys.Initialized() {
|
||||
@@ -1115,20 +1141,10 @@ func (sys *IAMSys) NewServiceAccount(ctx context.Context, parentUser string, gro
|
||||
return auth.Credentials{}, time.Time{}, auth.ErrNoAccessKeyWithSecretKey
|
||||
}
|
||||
|
||||
var policyBuf []byte
|
||||
if opts.sessionPolicy != nil {
|
||||
err := opts.sessionPolicy.Validate()
|
||||
policyBuf, err := marshalServiceAccountPolicy(opts.sessionPolicy)
|
||||
if err != nil {
|
||||
return auth.Credentials{}, time.Time{}, err
|
||||
}
|
||||
policyBuf, err = json.Marshal(opts.sessionPolicy)
|
||||
if err != nil {
|
||||
return auth.Credentials{}, time.Time{}, err
|
||||
}
|
||||
if len(policyBuf) > maxSVCSessionPolicySize {
|
||||
return auth.Credentials{}, time.Time{}, errSessionPolicyTooLarge
|
||||
}
|
||||
}
|
||||
|
||||
// found newly requested service account, to be same as
|
||||
// parentUser, reject such operations.
|
||||
@@ -1150,6 +1166,14 @@ func (sys *IAMSys) NewServiceAccount(ctx context.Context, parentUser string, gro
|
||||
|
||||
// Add all the necessary claims for the service account.
|
||||
for k, v := range opts.claims {
|
||||
// Older snapshots may carry this cache-only, decoded policy claim.
|
||||
if k == sessionPolicyNameExtracted {
|
||||
continue
|
||||
}
|
||||
// An explicit policy, including an empty one, owns this claim.
|
||||
if k == policy.SessionPolicyName && opts.sessionPolicy != nil {
|
||||
continue
|
||||
}
|
||||
_, ok := m[k]
|
||||
if !ok {
|
||||
m[k] = v
|
||||
@@ -1162,7 +1186,6 @@ func (sys *IAMSys) NewServiceAccount(ctx context.Context, parentUser string, gro
|
||||
}
|
||||
|
||||
var accessKey, secretKey string
|
||||
var err error
|
||||
if len(opts.accessKey) > 0 || len(opts.secretKey) > 0 {
|
||||
accessKey, secretKey = opts.accessKey, opts.secretKey
|
||||
} else {
|
||||
@@ -1211,6 +1234,9 @@ type updateServiceAccountOpts struct {
|
||||
status string
|
||||
name, description string
|
||||
expiration *time.Time
|
||||
// parentUser, when set, is the parent the caller was authorized for; an
|
||||
// account stored under another parent is refused as absent.
|
||||
parentUser string
|
||||
}
|
||||
|
||||
// UpdateServiceAccount - edit a service account
|
||||
@@ -1308,22 +1334,65 @@ 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 {
|
||||
if !ptok || !spok || pt != embeddedPolicyType {
|
||||
return nil, nil
|
||||
}
|
||||
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
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return policy.ParseConfig(bytes.NewReader(policyBytes))
|
||||
}
|
||||
|
||||
return sa, embeddedPolicy, nil
|
||||
// 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
|
||||
}
|
||||
// Extraction adds a decoded policy for local authorization. Transport only
|
||||
// the token's claims, so peers and imports do not sign this derived value.
|
||||
jwtClaims.Delete(sessionPolicyNameExtracted)
|
||||
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
|
||||
}
|
||||
}
|
||||
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
|
||||
@@ -1395,25 +1464,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() {
|
||||
@@ -1658,8 +1708,10 @@ func (sys *IAMSys) updateGroupMembershipsForLDAP(ctx context.Context) {
|
||||
continue
|
||||
}
|
||||
|
||||
cred.Groups = currGroups
|
||||
if err := sys.store.UpdateUserIdentity(ctx, cred); err != nil {
|
||||
// Only the groups change, on the credential as it is stored now:
|
||||
// cred is a snapshot from before the lookup, and a rotation, an
|
||||
// edit or a deletion since then must stand.
|
||||
if _, err := sys.store.RefreshUserGroups(ctx, cred, currGroups); err != nil {
|
||||
// Log and continue error - perhaps it'll work the next time.
|
||||
iamLogIf(GlobalContext, err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"maps"
|
||||
"time"
|
||||
|
||||
"github.com/minio/madmin-go/v3"
|
||||
)
|
||||
|
||||
type realtimeMetrics struct {
|
||||
madmin.RealtimeMetrics
|
||||
Aggregated realtimeMetricGroups `json:"aggregated"`
|
||||
ByHost map[string]realtimeMetricGroups `json:"by_host,omitempty"`
|
||||
}
|
||||
|
||||
type realtimeMetricGroups struct {
|
||||
madmin.Metrics
|
||||
BatchJobs *batchJobMetricsReport `json:"batchJobs,omitempty"`
|
||||
}
|
||||
|
||||
type batchJobMetricsReport struct {
|
||||
CollectedAt time.Time `json:"collected"`
|
||||
Jobs map[string]batchJobStatusMetric
|
||||
}
|
||||
|
||||
func (metrics *realtimeMetrics) Merge(other *realtimeMetrics) {
|
||||
if other == nil {
|
||||
return
|
||||
}
|
||||
metrics.RealtimeMetrics.Merge(&other.RealtimeMetrics)
|
||||
metrics.Aggregated.Merge(&other.Aggregated.Metrics)
|
||||
if other.Aggregated.BatchJobs != nil {
|
||||
if metrics.Aggregated.BatchJobs == nil {
|
||||
metrics.Aggregated.BatchJobs = &batchJobMetricsReport{Jobs: make(map[string]batchJobStatusMetric)}
|
||||
}
|
||||
jobs := metrics.Aggregated.BatchJobs
|
||||
if other.Aggregated.BatchJobs.CollectedAt.After(jobs.CollectedAt) {
|
||||
jobs.CollectedAt = other.Aggregated.BatchJobs.CollectedAt
|
||||
}
|
||||
for id, job := range other.Aggregated.BatchJobs.Jobs {
|
||||
if current, ok := jobs.Jobs[id]; !ok || !job.LastUpdate.Before(current.LastUpdate) {
|
||||
jobs.Jobs[id] = job
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(other.ByHost) != 0 {
|
||||
if metrics.ByHost == nil {
|
||||
metrics.ByHost = make(map[string]realtimeMetricGroups, len(other.ByHost))
|
||||
}
|
||||
maps.Copy(metrics.ByHost, other.ByHost)
|
||||
}
|
||||
}
|
||||
|
||||
func (metrics realtimeMetricGroups) legacy() madmin.Metrics {
|
||||
out := metrics.Metrics
|
||||
if metrics.BatchJobs != nil {
|
||||
out.BatchJobs = &madmin.BatchJobMetrics{
|
||||
CollectedAt: metrics.BatchJobs.CollectedAt,
|
||||
Jobs: make(map[string]madmin.JobMetric, len(metrics.BatchJobs.Jobs)),
|
||||
}
|
||||
for id, job := range metrics.BatchJobs.Jobs {
|
||||
out.BatchJobs.Jobs[id] = job.JobMetric
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (metrics realtimeMetrics) legacy() madmin.RealtimeMetrics {
|
||||
out := metrics.RealtimeMetrics
|
||||
out.Aggregated = metrics.Aggregated.legacy()
|
||||
if metrics.ByHost != nil {
|
||||
out.ByHost = make(map[string]madmin.Metrics, len(metrics.ByHost))
|
||||
for host, groups := range metrics.ByHost {
|
||||
out.ByHost[host] = groups.legacy()
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/minio/madmin-go/v3"
|
||||
"github.com/minio/minio/internal/grid"
|
||||
)
|
||||
|
||||
func TestBatchMetricsStatusSnapshots(t *testing.T) {
|
||||
fixture := newBatchAuthzFixture(t)
|
||||
job := BatchJobRequest{
|
||||
ID: "expire-metrics-snapshot" + getKeySeparator() + "0", Started: UTCNow(),
|
||||
Submitter: batchRootSubmitter(t), Expire: &BatchJobExpire{},
|
||||
}
|
||||
for _, state := range []string{"skipped", "paused", "failed", "running"} {
|
||||
t.Run(state, func(t *testing.T) {
|
||||
report := &batchJobInfo{JobID: job.ID, JobType: string(job.Type()), StartTime: job.Started}
|
||||
switch state {
|
||||
case "skipped":
|
||||
report.Complete = true
|
||||
report.VersionsSkippedLocked, report.KeysRefused = 2, 1
|
||||
case "paused":
|
||||
report.PausedReason = "authorization plugin unavailable"
|
||||
case "failed":
|
||||
report.Failed = true
|
||||
report.FailureReason = "authorization denied"
|
||||
}
|
||||
mustIAM(t, report.updateAfter(fixture.ctx, fixture.obj, 0, job))
|
||||
globalBatchJobsMetrics.save(job.ID, report)
|
||||
saved := batchJobStatusJSON(t, job.ID)
|
||||
for _, field := range []string{"startTime", "lastUpdate"} {
|
||||
stamp, err := time.Parse(time.RFC3339Nano, saved[field].(string))
|
||||
mustIAM(t, err)
|
||||
saved[field] = stamp.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
target := fmt.Sprintf("http://localhost/minio/admin/v3/metrics?types=%d&by-jobID=%s&n=1&by-host=true", madmin.MetricsBatchJobs, job.ID)
|
||||
request, err := newTestSignedRequestV4(http.MethodGet, target, 0, nil, globalActiveCred.AccessKey, globalActiveCred.SecretKey, nil)
|
||||
mustIAM(t, err)
|
||||
mustIAM(t, request.ParseForm())
|
||||
response := httptest.NewRecorder()
|
||||
request = request.WithContext(newContext(request, response, "Metrics"))
|
||||
adminAPIHandlers{}.MetricsHandler(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("metrics returned %d: %s", response.Code, response.Body.String())
|
||||
}
|
||||
var wire struct {
|
||||
Aggregated struct {
|
||||
BatchJobs struct{ Jobs map[string]map[string]any }
|
||||
}
|
||||
ByHost map[string]struct {
|
||||
BatchJobs struct{ Jobs map[string]map[string]any }
|
||||
} `json:"by_host"`
|
||||
}
|
||||
mustIAM(t, json.Unmarshal(response.Body.Bytes(), &wire))
|
||||
if !reflect.DeepEqual(wire.Aggregated.BatchJobs.Jobs[job.ID], saved) {
|
||||
t.Fatalf("live snapshot differs from saved status: live=%v saved=%v", wire.Aggregated.BatchJobs.Jobs[job.ID], saved)
|
||||
}
|
||||
if len(wire.ByHost) != 1 {
|
||||
t.Fatalf("missing per-host snapshot: %s", response.Body.String())
|
||||
}
|
||||
for _, host := range wire.ByHost {
|
||||
if !reflect.DeepEqual(host.BatchJobs.Jobs[job.ID], saved) {
|
||||
t.Fatal("per-host metrics lost details")
|
||||
}
|
||||
}
|
||||
var legacy madmin.RealtimeMetrics
|
||||
mustIAM(t, json.Unmarshal(response.Body.Bytes(), &legacy))
|
||||
if legacy.Aggregated.BatchJobs.Jobs[job.ID].JobID != job.ID || legacy.Aggregated.BatchJobs.Jobs[job.ID].Complete != report.Complete {
|
||||
t.Fatal("additive details changed legacy metrics")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchMetricsPeerRoundTrip(t *testing.T) {
|
||||
report := &batchJobInfo{JobID: "remote-job", LastUpdate: UTCNow(), PausedReason: "IAM unavailable", VersionsSkippedLocked: 3, KeysRefused: 2}
|
||||
remote := realtimeMetrics{}
|
||||
remote.Hosts = []string{"peer"}
|
||||
remote.Aggregated.BatchJobs = &batchJobMetricsReport{
|
||||
CollectedAt: UTCNow(), Jobs: map[string]batchJobStatusMetric{report.JobID: report.statusMetric()},
|
||||
}
|
||||
remote.ByHost = map[string]realtimeMetricGroups{"peer": remote.Aggregated}
|
||||
encoded := madminRealtimeMetrics.NewJSONWith(&remote)
|
||||
data, err := encoded.MarshalMsg(nil)
|
||||
mustIAM(t, err)
|
||||
decoded := madminRealtimeMetrics.NewJSON()
|
||||
defer decoded.Recycle()
|
||||
_, err = decoded.UnmarshalMsg(data)
|
||||
mustIAM(t, err)
|
||||
var merged realtimeMetrics
|
||||
received := decoded.ValueOrZero()
|
||||
merged.Merge(&received)
|
||||
got := merged.Aggregated.BatchJobs.Jobs[report.JobID]
|
||||
if got.PausedReason != report.PausedReason || got.KeysRefused != 2 || got.VersionsSkippedLocked == nil || *got.VersionsSkippedLocked != 3 {
|
||||
t.Fatalf("peer aggregation lost details: %+v", got)
|
||||
}
|
||||
older := received
|
||||
older.Aggregated.BatchJobs = &batchJobMetricsReport{Jobs: map[string]batchJobStatusMetric{
|
||||
report.JobID: {JobMetric: madmin.JobMetric{JobID: report.JobID, LastUpdate: report.LastUpdate.Add(-time.Second), Complete: true}},
|
||||
}}
|
||||
merged.Merge(&older)
|
||||
if got := merged.Aggregated.BatchJobs.Jobs[report.JobID]; got.Complete || got.PausedReason != report.PausedReason {
|
||||
t.Fatal("older peer snapshot replaced current progress")
|
||||
}
|
||||
legacyPool := grid.NewJSONPool[madmin.RealtimeMetrics]()
|
||||
legacy := legacyPool.NewJSON()
|
||||
defer legacy.Recycle()
|
||||
_, err = legacy.UnmarshalMsg(data)
|
||||
mustIAM(t, err)
|
||||
if got := legacy.ValueOrZero().Aggregated.BatchJobs.Jobs[report.JobID]; got.JobID != report.JobID || !got.LastUpdate.Equal(report.LastUpdate) {
|
||||
t.Fatal("legacy peer could not read base metrics")
|
||||
}
|
||||
legacyData, err := legacy.MarshalMsg(nil)
|
||||
mustIAM(t, err)
|
||||
_, err = decoded.UnmarshalMsg(legacyData)
|
||||
mustIAM(t, err)
|
||||
received = decoded.ValueOrZero()
|
||||
if received.Aggregated.BatchJobs.Jobs[report.JobID].VersionsSkippedLocked != nil {
|
||||
t.Fatal("legacy peer falsely advertised complete status details")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchMetricsPreservesOtherMetrics(t *testing.T) {
|
||||
var legacy madmin.RealtimeMetrics
|
||||
var extended realtimeMetrics
|
||||
for _, host := range []string{"first", "second"} {
|
||||
groups := madmin.Metrics{
|
||||
Disk: &madmin.DiskMetric{CollectedAt: time.Unix(1, 0).UTC(), NDisks: 2, Offline: 1},
|
||||
CPU: &madmin.CPUMetrics{CPUCount: 4},
|
||||
}
|
||||
sample := madmin.RealtimeMetrics{
|
||||
Hosts: []string{host}, Errors: []string{host + ": test"},
|
||||
Aggregated: groups, ByHost: map[string]madmin.Metrics{host: groups},
|
||||
ByDisk: map[string]madmin.DiskMetric{host: *groups.Disk},
|
||||
}
|
||||
data, err := json.Marshal(sample)
|
||||
mustIAM(t, err)
|
||||
var decoded realtimeMetrics
|
||||
mustIAM(t, json.Unmarshal(data, &decoded))
|
||||
extended.Merge(&decoded)
|
||||
legacy.Merge(&sample)
|
||||
}
|
||||
if got := extended.legacy(); !reflect.DeepEqual(got, legacy) {
|
||||
t.Fatalf("batch extension changed other metrics: got=%+v want=%+v", got, legacy)
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,7 @@ type collectMetricsOpts struct {
|
||||
depID string
|
||||
}
|
||||
|
||||
func collectLocalMetrics(types madmin.MetricType, opts collectMetricsOpts) (m madmin.RealtimeMetrics) {
|
||||
func collectLocalMetrics(types madmin.MetricType, opts collectMetricsOpts) (m realtimeMetrics) {
|
||||
if types == madmin.MetricsNone {
|
||||
return m
|
||||
}
|
||||
@@ -144,7 +144,7 @@ func collectLocalMetrics(types madmin.MetricType, opts collectMetricsOpts) (m ma
|
||||
// Add types...
|
||||
|
||||
// ByHost is a shallow reference, so careful about sharing.
|
||||
m.ByHost = map[string]madmin.Metrics{byHostName: m.Aggregated}
|
||||
m.ByHost = map[string]realtimeMetricGroups{byHostName: m.Aggregated}
|
||||
m.Hosts = append(m.Hosts, byHostName)
|
||||
|
||||
return m
|
||||
@@ -221,11 +221,11 @@ func collectLocalDisksMetrics(disks map[string]struct{}) map[string]madmin.DiskM
|
||||
return metrics
|
||||
}
|
||||
|
||||
func collectRemoteMetrics(ctx context.Context, types madmin.MetricType, opts collectMetricsOpts) (m madmin.RealtimeMetrics) {
|
||||
func collectRemoteMetrics(ctx context.Context, types madmin.MetricType, opts collectMetricsOpts) (m realtimeMetrics) {
|
||||
if !globalIsDistErasure {
|
||||
return m
|
||||
}
|
||||
all := globalNotificationSys.GetMetrics(ctx, types, opts)
|
||||
all := globalNotificationSys.getMetrics(ctx, types, opts)
|
||||
for _, remote := range all {
|
||||
m.Merge(&remote)
|
||||
}
|
||||
|
||||
@@ -246,7 +246,7 @@ func updateDriveIOStats(currentStats madmin.DiskIOStats, latestStats madmin.Disk
|
||||
updateResourceMetrics(driveSubsystem, percUtil, float64(diffStats.TotalTicks)/(diffInSeconds*10), labels, false)
|
||||
}
|
||||
|
||||
func collectDriveMetrics(m madmin.RealtimeMetrics) {
|
||||
func collectDriveMetrics(m realtimeMetrics) {
|
||||
latestDriveStatsMu.Lock()
|
||||
for d, dm := range m.ByDisk {
|
||||
labels := map[string]string{"drive": d}
|
||||
|
||||
+1
-1
@@ -3804,7 +3804,7 @@ func getBatchJobsMetrics(opts MetricsGroupOpts) *MetricsGroupV2 {
|
||||
}
|
||||
|
||||
mg.RegisterRead(func(ctx context.Context) (metrics []MetricV2) {
|
||||
var m madmin.RealtimeMetrics
|
||||
var m realtimeMetrics
|
||||
mLocal := collectLocalMetrics(madmin.MetricsBatchJobs, collectMetricsOpts{})
|
||||
m.Merge(&mLocal)
|
||||
|
||||
|
||||
+3
-3
@@ -873,9 +873,9 @@ func (sys *NotificationSys) GetOSInfo(ctx context.Context) []madmin.OSInfo {
|
||||
return reply
|
||||
}
|
||||
|
||||
// GetMetrics - Get metrics from all peers.
|
||||
func (sys *NotificationSys) GetMetrics(ctx context.Context, t madmin.MetricType, opts collectMetricsOpts) []madmin.RealtimeMetrics {
|
||||
reply := make([]madmin.RealtimeMetrics, len(sys.peerClients))
|
||||
// getMetrics - Get metrics from all peers.
|
||||
func (sys *NotificationSys) getMetrics(ctx context.Context, t madmin.MetricType, opts collectMetricsOpts) []realtimeMetrics {
|
||||
reply := make([]realtimeMetrics, len(sys.peerClients))
|
||||
|
||||
g := errgroup.WithNErrs(len(sys.peerClients))
|
||||
for index, client := range sys.peerClients {
|
||||
|
||||
@@ -122,6 +122,8 @@ type ObjectOptions struct {
|
||||
|
||||
// Pools-layer version resolver; the caller holds the shared object lock.
|
||||
replicaObjectInfo GetObjectInfoFn
|
||||
// Verifies protected content in every pool before a replica commits.
|
||||
replicaContentGuard replicaContentGuardFn
|
||||
|
||||
// RefuseLockedOverwrite refuses, under the destination's write lock, a
|
||||
// write that would replace a stored version Object Lock protects: the
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
// Copyright (c) 2026 Denis Babchuk
|
||||
//
|
||||
// This file is part of Silo Object Storage stack
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/minio/minio/internal/auth"
|
||||
xhttp "github.com/minio/minio/internal/http"
|
||||
)
|
||||
|
||||
// GetObjectAttributes answers NoSuchKey with AccessDenied to a caller without
|
||||
// s3:ListBucket on the bucket, as S3 does, so that a missing object cannot be
|
||||
// told apart from a forbidden one. A server error, such as lost read quorum,
|
||||
// must reach a caller allowed to read the object as the error it is.
|
||||
func TestGetObjectAttributesReadQuorumLost(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
obj, dirs, err := prepareErasure16(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
z := obj.(*erasureServerPools)
|
||||
previous := newObjectLayerFn()
|
||||
setObjectLayer(z)
|
||||
defer func() {
|
||||
z.Shutdown(context.Background())
|
||||
removeRoots(dirs)
|
||||
setObjectLayer(previous)
|
||||
}()
|
||||
bucket, router, err := initAPIHandlerTest(ctx, z, nil, MakeBucketOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const object, missing = "object", "missing"
|
||||
if _, err := z.PutObject(ctx, bucket, object, mustGetPutObjReader(t, bytes.NewReader([]byte("data")), 4, "", ""), ObjectOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
read := `{"Effect": "Allow", "Action": ["s3:GetObject", "s3:GetObjectAttributes"], "Resource": ["arn:aws:s3:::` + bucket + `/*"]}`
|
||||
withoutList := newDeleteAuthzPolicyUser(t, "", bucket, "["+read+"]")
|
||||
withList := newDeleteAuthzPolicyUser(t, "", bucket, "["+read+
|
||||
`, {"Effect": "Allow", "Action": ["s3:ListBucket"], "Resource": ["arn:aws:s3:::`+bucket+`"]}]`)
|
||||
|
||||
check := func(stage, user string, creds auth.Credentials, key string, want int) {
|
||||
t.Helper()
|
||||
target := makeTestTargetURL("", bucket, key, url.Values{"attributes": []string{""}})
|
||||
req, err := newTestSignedRequestV4(http.MethodGet, target, 0, nil, creds.AccessKey, creds.SecretKey,
|
||||
map[string]string{xhttp.AmzObjectAttributes: "ETag"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != want {
|
||||
var apiErr APIErrorResponse
|
||||
_ = xml.Unmarshal(rec.Body.Bytes(), &apiErr)
|
||||
t.Errorf("%s: GetObjectAttributes %s by %s: status %d %s, want %d", stage, key, user, rec.Code, apiErr.Code, want)
|
||||
}
|
||||
}
|
||||
|
||||
check("healthy", "withoutList", withoutList, object, http.StatusOK)
|
||||
check("healthy", "withList", withList, object, http.StatusOK)
|
||||
check("healthy", "withoutList", withoutList, missing, http.StatusForbidden)
|
||||
check("healthy", "withList", withList, missing, http.StatusNotFound)
|
||||
|
||||
defer objectReadFault(z, bucket, object, 10)()
|
||||
check("read quorum lost", "withoutList", withoutList, object, http.StatusServiceUnavailable)
|
||||
check("read quorum lost", "withList", withList, object, http.StatusServiceUnavailable)
|
||||
}
|
||||
+181
-56
@@ -395,12 +395,14 @@ func (api objectAPIHandlers) getObjectHandler(ctx context.Context, objectAPI Obj
|
||||
}
|
||||
|
||||
// Validate pre-conditions if any.
|
||||
var storedTags *string // set once the object has been read far enough
|
||||
opts.CheckPrecondFn = func(oi ObjectInfo) bool {
|
||||
if _, err := DecryptObjectInfo(&oi, r); err != nil {
|
||||
writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL)
|
||||
return true
|
||||
}
|
||||
|
||||
storedTags = &oi.UserTags
|
||||
if s3Error := authorizeRequestWithExistingTags(ctx, r, policy.GetObjectAction, oi.UserTags); s3Error != ErrNone {
|
||||
writeErrorResponse(ctx, w, errorCodes.ToAPIErr(s3Error), r.URL)
|
||||
return true
|
||||
@@ -445,8 +447,14 @@ func (api objectAPIHandlers) getObjectHandler(ctx context.Context, objectAPI Obj
|
||||
return
|
||||
}
|
||||
// validate if the request indeed was authorized, if it wasn't we need to return "ErrAccessDenied"
|
||||
// instead of any namespace related error.
|
||||
if s3Error := authorizeRequest(ctx, r, policy.GetObjectAction); s3Error != ErrNone {
|
||||
// instead of any namespace related error. Use the stored tags if the callback read them.
|
||||
var s3Error APIErrorCode
|
||||
if storedTags != nil {
|
||||
s3Error = authorizeRequestWithExistingTags(ctx, r, policy.GetObjectAction, *storedTags)
|
||||
} else {
|
||||
s3Error = authorizeRequestWithUnreadTags(ctx, r, policy.GetObjectAction, err)
|
||||
}
|
||||
if s3Error != ErrNone {
|
||||
writeErrorResponse(ctx, w, errorCodes.ToAPIErr(s3Error), r.URL)
|
||||
return
|
||||
}
|
||||
@@ -623,12 +631,15 @@ func (api objectAPIHandlers) getObjectAttributesHandler(ctx context.Context, obj
|
||||
|
||||
objInfo, err := objectAPI.GetObjectInfo(ctx, bucket, object, opts)
|
||||
if err != nil {
|
||||
s3Error = authorizeRequest(ctx, r, policy.ListBucketAction)
|
||||
if s3Error == ErrNone {
|
||||
writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL)
|
||||
return
|
||||
// As with S3, a caller without s3:ListBucket on the bucket gets
|
||||
// AccessDenied in place of NoSuchKey. A server error, such as lost
|
||||
// read quorum, tells nothing about the object and reaches the caller,
|
||||
// who may read the object, as it is.
|
||||
apiErr := toAPIError(ctx, err)
|
||||
if apiErr.HTTPStatusCode < http.StatusInternalServerError && authorizeBucketRequest(ctx, r, policy.ListBucketAction) != ErrNone {
|
||||
apiErr = errorCodes.ToAPIErr(ErrAccessDenied)
|
||||
}
|
||||
writeErrorResponse(ctx, w, errorCodes.ToAPIErr(ErrAccessDenied), r.URL)
|
||||
writeErrorResponse(ctx, w, apiErr, r.URL)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -925,7 +936,14 @@ func (api objectAPIHandlers) headObjectHandler(ctx context.Context, objectAPI Ob
|
||||
}
|
||||
}
|
||||
|
||||
if s3Error := authorizeRequestWithExistingTags(ctx, r, policy.GetObjectAction, objInfo.UserTags); s3Error != ErrNone {
|
||||
// objInfo carries the stored tags unless reading the object failed.
|
||||
var s3Error APIErrorCode
|
||||
if err != nil && !proxy.Proxy {
|
||||
s3Error = authorizeRequestWithUnreadTags(ctx, r, policy.GetObjectAction, err)
|
||||
} else {
|
||||
s3Error = authorizeRequestWithExistingTags(ctx, r, policy.GetObjectAction, objInfo.UserTags)
|
||||
}
|
||||
if s3Error != ErrNone {
|
||||
writeErrorResponseHeadersOnly(w, errorCodes.ToAPIErr(s3Error))
|
||||
return
|
||||
}
|
||||
@@ -1829,12 +1847,27 @@ func (api objectAPIHandlers) CopyObjectHandler(w http.ResponseWriter, r *http.Re
|
||||
|
||||
srcInfo.PutObjReader = pReader
|
||||
|
||||
// Object Lock state as stored on disk, captured before the metadata
|
||||
// directive rebuilds the map. A replica update is applied only when its
|
||||
// source timestamp is newer than the stored one, and a stale update must
|
||||
// leave the stored state in place instead of erasing it.
|
||||
storedLock := storedObjectLockState(srcInfo.UserDefined)
|
||||
storedTagTimestamp := srcInfo.UserDefined[ReservedMetadataPrefixLower+TaggingTimestamp]
|
||||
// Reconcile against the addressed destination version under its write
|
||||
// lock. The source can be a different object or version, including when
|
||||
// the destination version has never been written.
|
||||
getObjectInfo := func(_ context.Context, bucket, object string, opts ObjectOptions) (ObjectInfo, error) {
|
||||
opts.NoLock = true
|
||||
return objectAPI.GetObjectInfo(lockCtx, bucket, object, opts)
|
||||
}
|
||||
var storedInfo ObjectInfo
|
||||
if replicaTrusted && !remoteCallRequired {
|
||||
storedInfo, err = replicaStoredObjectInfo(lockCtx, getObjectInfo, dstBucket, dstObject, dstOpts.VersionID)
|
||||
if err != nil {
|
||||
writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL)
|
||||
return
|
||||
}
|
||||
}
|
||||
storedLock := storedObjectLockState(storedInfo.UserDefined)
|
||||
storedTagTimestamp := storedInfo.UserDefined[ReservedMetadataPrefixLower+TaggingTimestamp]
|
||||
localTagTimestamp := ""
|
||||
if cpSrcDstSame {
|
||||
localTagTimestamp = srcInfo.UserDefined[ReservedMetadataPrefixLower+TaggingTimestamp]
|
||||
}
|
||||
|
||||
srcInfo.UserDefined, err = getCpObjMetadataFromHeader(ctx, r, srcInfo.UserDefined, allowReplicationMetadata)
|
||||
if err != nil {
|
||||
@@ -1859,9 +1892,9 @@ func (api objectAPIHandlers) CopyObjectHandler(w http.ResponseWriter, r *http.Re
|
||||
// the captured state even if metadata REPLACE rebuilt the map.
|
||||
srcInfo.UserDefined[ReservedMetadataPrefixLower+TaggingTimestamp] = srcTimestamp.UTC().Format(time.RFC3339Nano)
|
||||
srcInfo.UserDefined[xhttp.AmzObjectTagging] = objTags
|
||||
reconcileStoredObjectTags(srcInfo.UserDefined, srcInfo.UserTags, storedTagTimestamp)
|
||||
reconcileStoredObjectTags(srcInfo.UserDefined, storedInfo.UserTags, storedTagTimestamp)
|
||||
} else {
|
||||
srcInfo.UserDefined[xhttp.AmzObjectTagging] = srcInfo.UserTags
|
||||
srcInfo.UserDefined[xhttp.AmzObjectTagging] = storedInfo.UserTags
|
||||
if storedTagTimestamp != "" {
|
||||
srcInfo.UserDefined[ReservedMetadataPrefixLower+TaggingTimestamp] = storedTagTimestamp
|
||||
} else {
|
||||
@@ -1870,7 +1903,7 @@ func (api objectAPIHandlers) CopyObjectHandler(w http.ResponseWriter, r *http.Re
|
||||
}
|
||||
} else {
|
||||
srcInfo.UserDefined[xhttp.AmzObjectTagging] = objTags
|
||||
srcInfo.UserDefined[ReservedMetadataPrefixLower+TaggingTimestamp] = UTCNow().Format(time.RFC3339Nano)
|
||||
srcInfo.UserDefined[ReservedMetadataPrefixLower+TaggingTimestamp] = monotonicMetadataTimestamp(UTCNow().Format(time.RFC3339Nano), localTagTimestamp)
|
||||
}
|
||||
// SSE-C rotation snapshots reserved metadata before the tag decision. Its
|
||||
// later merge must not put the old timestamp back over the accepted state.
|
||||
@@ -1878,14 +1911,12 @@ func (api objectAPIHandlers) CopyObjectHandler(w http.ResponseWriter, r *http.Re
|
||||
|
||||
srcInfo.UserDefined = filterReplicationStatusMetadata(srcInfo.UserDefined)
|
||||
srcInfo.UserDefined = objectlock.FilterObjectLockMetadata(srcInfo.UserDefined, true, true)
|
||||
// COPY may have preserved the source's reserved timestamps. Only the
|
||||
// addressed destination and the incoming update may order a replica.
|
||||
delete(srcInfo.UserDefined, ReservedMetadataPrefixLower+ObjectLockRetentionTimestamp)
|
||||
delete(srcInfo.UserDefined, ReservedMetadataPrefixLower+ObjectLockLegalHoldTimestamp)
|
||||
retPerms := isPutActionAllowed(ctx, getRequestAuthType(r), dstBucket, dstObject, r, policy.PutObjectRetentionAction)
|
||||
holdPerms := isPutActionAllowed(ctx, getRequestAuthType(r), dstBucket, dstObject, r, policy.PutObjectLegalHoldAction)
|
||||
// The destination is locked above: look it up without its lock, in the
|
||||
// locks' context.
|
||||
getObjectInfo := func(_ context.Context, bucket, object string, opts ObjectOptions) (ObjectInfo, error) {
|
||||
opts.NoLock = true
|
||||
return objectAPI.GetObjectInfo(lockCtx, bucket, object, opts)
|
||||
}
|
||||
|
||||
// apply default bucket configuration/governance headers for dest side.
|
||||
retentionMode, retentionDate, legalHold, s3Err := checkPutObjectLockAllowed(ctx, r, dstBucket, dstObject, getObjectInfo, retPerms, holdPerms, replicaTrusted)
|
||||
@@ -3140,6 +3171,22 @@ func (api objectAPIHandlers) DeleteObjectHandler(w http.ResponseWriter, r *http.
|
||||
}
|
||||
}
|
||||
|
||||
// Check before delOpts reads bucket versioning: unavailable bucket metadata
|
||||
// must fail the request before a force-delete can reach the object layer.
|
||||
if forceDelete, _ := strconv.ParseBool(r.Header.Get(xhttp.MinIOForceDelete)); forceDelete {
|
||||
rcfg, err := globalBucketObjectLockSys.Get(bucket)
|
||||
if err != nil {
|
||||
writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL)
|
||||
return
|
||||
}
|
||||
if rcfg.LockEnabled {
|
||||
apiErr := toAPIError(ctx, errInvalidArgument)
|
||||
apiErr.Description = "force-delete is forbidden on Object Locking enabled buckets"
|
||||
writeErrorResponse(ctx, w, apiErr, r.URL)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
opts, err := delOpts(ctx, r, bucket, object)
|
||||
if err != nil {
|
||||
writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL)
|
||||
@@ -3160,14 +3207,6 @@ func (api objectAPIHandlers) DeleteObjectHandler(w http.ResponseWriter, r *http.
|
||||
}
|
||||
}
|
||||
|
||||
rcfg, _ := globalBucketObjectLockSys.Get(bucket)
|
||||
if rcfg.LockEnabled && opts.DeletePrefix {
|
||||
apiErr := toAPIError(ctx, errInvalidArgument)
|
||||
apiErr.Description = "force-delete is forbidden on Object Locking enabled buckets"
|
||||
writeErrorResponse(ctx, w, apiErr, r.URL)
|
||||
return
|
||||
}
|
||||
|
||||
os := newObjSweeper(bucket, object).WithVersion(opts.VersionID).WithVersioning(opts.Versioned, opts.VersionSuspended)
|
||||
|
||||
opts.SetEvalMetadataFn(func(oi *ObjectInfo, gerr error) (dsc ReplicateDecision, err error) {
|
||||
@@ -3193,9 +3232,12 @@ func (api objectAPIHandlers) DeleteObjectHandler(w http.ResponseWriter, r *http.
|
||||
vID := opts.VersionID
|
||||
if replica {
|
||||
opts.SetReplicaStatus(replication.Replica)
|
||||
if opts.VersionPurgeStatus().Empty() {
|
||||
// opts.VersionID holds delete marker version ID to replicate and not yet present on disk
|
||||
if opts.DeleteMarker && opts.VersionPurgeStatus().Empty() {
|
||||
// opts.VersionID names the delete marker to replicate, not yet
|
||||
// present here. It must not replace a stored version this site
|
||||
// protects; a replicated purge keeps the retention check below.
|
||||
vID = ""
|
||||
opts.RefuseLockedOverwrite = true
|
||||
}
|
||||
}
|
||||
opts.SetEvalRetentionBypassFn(func(goi ObjectInfo, gerr error) (err error) {
|
||||
@@ -3363,7 +3405,7 @@ func (api objectAPIHandlers) PutObjectLegalHoldHandler(w http.ResponseWriter, r
|
||||
VersionID: opts.VersionID,
|
||||
EvalMetadataFn: func(oi *ObjectInfo, gerr error) (ReplicateDecision, error) {
|
||||
oi.UserDefined[strings.ToLower(xhttp.AmzObjectLockLegalHold)] = strings.ToUpper(string(legalHold.Status))
|
||||
oi.UserDefined[ReservedMetadataPrefixLower+ObjectLockLegalHoldTimestamp] = UTCNow().Format(time.RFC3339Nano)
|
||||
oi.UserDefined[ReservedMetadataPrefixLower+ObjectLockLegalHoldTimestamp] = monotonicMetadataTimestamp(UTCNow().Format(time.RFC3339Nano), oi.UserDefined[ReservedMetadataPrefixLower+ObjectLockLegalHoldTimestamp])
|
||||
|
||||
dsc := mustReplicate(ctx, bucket, object, oi.getMustReplicateOptions(replication.MetadataReplicationType, opts))
|
||||
if dsc.ReplicateAny() {
|
||||
@@ -3540,7 +3582,7 @@ func (api objectAPIHandlers) PutObjectRetentionHandler(w http.ResponseWriter, r
|
||||
oi.UserDefined[strings.ToLower(xhttp.AmzObjectLockMode)] = ""
|
||||
oi.UserDefined[strings.ToLower(xhttp.AmzObjectLockRetainUntilDate)] = ""
|
||||
}
|
||||
oi.UserDefined[ReservedMetadataPrefixLower+ObjectLockRetentionTimestamp] = UTCNow().Format(time.RFC3339Nano)
|
||||
oi.UserDefined[ReservedMetadataPrefixLower+ObjectLockRetentionTimestamp] = monotonicMetadataTimestamp(UTCNow().Format(time.RFC3339Nano), oi.UserDefined[ReservedMetadataPrefixLower+ObjectLockRetentionTimestamp])
|
||||
dsc = mustReplicate(ctx, bucket, object, oi.getMustReplicateOptions(replication.MetadataReplicationType, opts))
|
||||
if dsc.ReplicateAny() {
|
||||
oi.UserDefined[ReservedMetadataPrefixLower+ReplicationTimestamp] = UTCNow().Format(time.RFC3339Nano)
|
||||
@@ -3684,9 +3726,22 @@ func (api objectAPIHandlers) GetObjectTaggingHandler(w http.ResponseWriter, r *h
|
||||
if isErrObjectNotFound(err) || isErrVersionNotFound(err) {
|
||||
proxytgts := getProxyTargets(ctx, bucket, object, opts)
|
||||
if !proxytgts.Empty() {
|
||||
if s3Error := authorizeRequestWithPotentialTags(ctx, r, policy.GetObjectTaggingAction); s3Error != ErrNone {
|
||||
writeErrorResponse(ctx, w, errorCodes.ToAPIErr(s3Error), r.URL)
|
||||
return
|
||||
}
|
||||
globalReplicationStats.Load().incProxy(bucket, getObjectTaggingAPI, false)
|
||||
// proxy to replication target if site replication is in place.
|
||||
tags, gerr := proxyGetTaggingToRepTarget(ctx, bucket, object, opts, proxytgts)
|
||||
tags, gerr, s3Error := proxyGetTaggingToRepTarget(ctx, bucket, object, opts, proxytgts, func(existingTags string, readErr error) APIErrorCode {
|
||||
if readErr != nil && !isRemoteTaggingNotFound(readErr) {
|
||||
return authorizeRequestWithUnreadTags(ctx, r, policy.GetObjectTaggingAction, readErr)
|
||||
}
|
||||
return authorizeRequestWithExistingTags(ctx, r, policy.GetObjectTaggingAction, existingTags)
|
||||
})
|
||||
if s3Error != ErrNone {
|
||||
writeErrorResponse(ctx, w, errorCodes.ToAPIErr(s3Error), r.URL)
|
||||
return
|
||||
}
|
||||
if gerr.Err != nil || tags == nil {
|
||||
globalReplicationStats.Load().incProxy(bucket, getObjectTaggingAPI, true)
|
||||
writeErrorResponse(ctx, w, toAPIError(ctx, gerr.Err), r.URL)
|
||||
@@ -3695,10 +3750,18 @@ func (api objectAPIHandlers) GetObjectTaggingHandler(w http.ResponseWriter, r *h
|
||||
ot = tags
|
||||
w.Header()[xhttp.MinIOTaggingProxied] = []string{"true"} // indicate that the request was proxied.
|
||||
} else {
|
||||
if s3Error := authorizeRequestWithExistingTags(ctx, r, policy.GetObjectTaggingAction, ""); s3Error != ErrNone {
|
||||
writeErrorResponse(ctx, w, errorCodes.ToAPIErr(s3Error), r.URL)
|
||||
return
|
||||
}
|
||||
writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if s3Error := authorizeRequestWithUnreadTags(ctx, r, policy.GetObjectTaggingAction, err); s3Error != ErrNone {
|
||||
writeErrorResponse(ctx, w, errorCodes.ToAPIErr(s3Error), r.URL)
|
||||
return
|
||||
}
|
||||
writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL)
|
||||
return
|
||||
}
|
||||
@@ -3733,6 +3796,18 @@ func (api objectAPIHandlers) GetObjectTaggingHandler(w http.ResponseWriter, r *h
|
||||
writeSuccessResponseXML(w, encodeResponse(otags))
|
||||
}
|
||||
|
||||
func isRemoteTaggingNotFound(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
switch miniogo.ToErrorResponse(err).Code {
|
||||
case "NoSuchKey", "NoSuchVersion":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// PutObjectTaggingHandler - PUT object tagging
|
||||
func (api objectAPIHandlers) PutObjectTaggingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := newContext(r, w, "PutObjectTagging")
|
||||
@@ -3752,6 +3827,13 @@ func (api objectAPIHandlers) PutObjectTaggingHandler(w http.ResponseWriter, r *h
|
||||
return
|
||||
}
|
||||
|
||||
logger.GetReqInfo(ctx).BucketName = bucket
|
||||
logger.GetReqInfo(ctx).ObjectName = object
|
||||
if s3Error := authenticateRequest(ctx, r, policy.PutObjectTaggingAction); s3Error != ErrNone {
|
||||
writeErrorResponse(ctx, w, errorCodes.ToAPIErr(s3Error), r.URL)
|
||||
return
|
||||
}
|
||||
|
||||
// Tags XML will not be bigger than 1MiB in size, fail if its bigger.
|
||||
tags, err := tags.ParseObjectXML(io.LimitReader(r.Body, 1<<20))
|
||||
if err != nil {
|
||||
@@ -3760,13 +3842,6 @@ func (api objectAPIHandlers) PutObjectTaggingHandler(w http.ResponseWriter, r *h
|
||||
}
|
||||
tagsStr := tags.String()
|
||||
|
||||
logger.GetReqInfo(ctx).BucketName = bucket
|
||||
logger.GetReqInfo(ctx).ObjectName = object
|
||||
if s3Error := authenticateRequest(ctx, r, policy.PutObjectTaggingAction); s3Error != ErrNone {
|
||||
writeErrorResponse(ctx, w, errorCodes.ToAPIErr(s3Error), r.URL)
|
||||
return
|
||||
}
|
||||
|
||||
// Set this such that authorization policies can be applied on the object
|
||||
// tags. This is derived from the request body, so it must be injected only
|
||||
// after signature verification: the SigV4 verifier now rejects unsigned
|
||||
@@ -3780,14 +3855,6 @@ func (api objectAPIHandlers) PutObjectTaggingHandler(w http.ResponseWriter, r *h
|
||||
}
|
||||
|
||||
objInfo, err := objAPI.GetObjectInfo(ctx, bucket, object, opts)
|
||||
existingTags := ""
|
||||
if err == nil {
|
||||
existingTags = objInfo.UserTags
|
||||
}
|
||||
if s3Error := authorizeRequestWithExistingTags(ctx, r, policy.PutObjectTaggingAction, existingTags); s3Error != ErrNone {
|
||||
writeErrorResponse(ctx, w, errorCodes.ToAPIErr(s3Error), r.URL)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
// if object is not found locally, but exists on peer site - proxy
|
||||
// the tagging request to peer site. The response to client will
|
||||
@@ -3795,9 +3862,21 @@ func (api objectAPIHandlers) PutObjectTaggingHandler(w http.ResponseWriter, r *h
|
||||
if isErrObjectNotFound(err) || isErrVersionNotFound(err) {
|
||||
proxytgts := getProxyTargets(ctx, bucket, object, opts)
|
||||
if !proxytgts.Empty() {
|
||||
if s3Error := authorizeRequestWithPotentialTags(ctx, r, policy.PutObjectTaggingAction); s3Error != ErrNone {
|
||||
writeErrorResponse(ctx, w, errorCodes.ToAPIErr(s3Error), r.URL)
|
||||
return
|
||||
}
|
||||
perr, s3Error := proxyTaggingToRepTarget(ctx, bucket, object, tags, opts, proxytgts, func(existingTags string, readErr error) APIErrorCode {
|
||||
if readErr != nil && !isRemoteTaggingNotFound(readErr) {
|
||||
return authorizeRequestWithUnreadTags(ctx, r, policy.PutObjectTaggingAction, readErr)
|
||||
}
|
||||
return authorizeRequestWithExistingTags(ctx, r, policy.PutObjectTaggingAction, existingTags)
|
||||
})
|
||||
if s3Error != ErrNone {
|
||||
writeErrorResponse(ctx, w, errorCodes.ToAPIErr(s3Error), r.URL)
|
||||
return
|
||||
}
|
||||
globalReplicationStats.Load().incProxy(bucket, putObjectTaggingAPI, false)
|
||||
// proxy to replication target if site replication is in place.
|
||||
perr := proxyTaggingToRepTarget(ctx, bucket, object, tags, opts, proxytgts)
|
||||
if perr.Err != nil {
|
||||
globalReplicationStats.Load().incProxy(bucket, putObjectTaggingAPI, true)
|
||||
writeErrorResponse(ctx, w, toAPIError(ctx, perr.Err), r.URL)
|
||||
@@ -3818,9 +3897,26 @@ func (api objectAPIHandlers) PutObjectTaggingHandler(w http.ResponseWriter, r *h
|
||||
return
|
||||
}
|
||||
}
|
||||
// A local read failure must never be turned into a write grant. A
|
||||
// server error may still be disclosed to an otherwise authorized
|
||||
// caller for retry, while a missing object is evaluated as untagged.
|
||||
var s3Error APIErrorCode
|
||||
if isErrObjectNotFound(err) || isErrVersionNotFound(err) {
|
||||
s3Error = authorizeRequestWithExistingTags(ctx, r, policy.PutObjectTaggingAction, "")
|
||||
} else {
|
||||
s3Error = authorizeRequestWithUnreadTags(ctx, r, policy.PutObjectTaggingAction, err)
|
||||
}
|
||||
if s3Error != ErrNone {
|
||||
writeErrorResponse(ctx, w, errorCodes.ToAPIErr(s3Error), r.URL)
|
||||
return
|
||||
}
|
||||
writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL)
|
||||
return
|
||||
}
|
||||
if s3Error := authorizeRequestWithExistingTags(ctx, r, policy.PutObjectTaggingAction, objInfo.UserTags); s3Error != ErrNone {
|
||||
writeErrorResponse(ctx, w, errorCodes.ToAPIErr(s3Error), r.URL)
|
||||
return
|
||||
}
|
||||
|
||||
dsc := mustReplicate(ctx, bucket, object, getMustReplicateOptions(objInfo.UserDefined, tagsStr, objInfo.ReplicationStatus, replication.MetadataReplicationType, opts))
|
||||
stamp := UTCNow().Format(time.RFC3339Nano)
|
||||
@@ -3877,6 +3973,13 @@ func (api objectAPIHandlers) DeleteObjectTaggingHandler(w http.ResponseWriter, r
|
||||
return
|
||||
}
|
||||
|
||||
logger.GetReqInfo(ctx).BucketName = bucket
|
||||
logger.GetReqInfo(ctx).ObjectName = object
|
||||
if s3Error := authenticateRequest(ctx, r, policy.DeleteObjectTaggingAction); s3Error != ErrNone {
|
||||
writeErrorResponse(ctx, w, errorCodes.ToAPIErr(s3Error), r.URL)
|
||||
return
|
||||
}
|
||||
|
||||
opts, err := getOpts(ctx, r, bucket, object)
|
||||
if err != nil {
|
||||
writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL)
|
||||
@@ -3891,9 +3994,21 @@ func (api objectAPIHandlers) DeleteObjectTaggingHandler(w http.ResponseWriter, r
|
||||
if isErrObjectNotFound(err) || isErrVersionNotFound(err) {
|
||||
proxytgts := getProxyTargets(ctx, bucket, object, opts)
|
||||
if !proxytgts.Empty() {
|
||||
if s3Error := authorizeRequestWithPotentialTags(ctx, r, policy.DeleteObjectTaggingAction); s3Error != ErrNone {
|
||||
writeErrorResponse(ctx, w, errorCodes.ToAPIErr(s3Error), r.URL)
|
||||
return
|
||||
}
|
||||
perr, s3Error := proxyTaggingToRepTarget(ctx, bucket, object, nil, opts, proxytgts, func(existingTags string, readErr error) APIErrorCode {
|
||||
if readErr != nil && !isRemoteTaggingNotFound(readErr) {
|
||||
return authorizeRequestWithUnreadTags(ctx, r, policy.DeleteObjectTaggingAction, readErr)
|
||||
}
|
||||
return authorizeRequestWithExistingTags(ctx, r, policy.DeleteObjectTaggingAction, existingTags)
|
||||
})
|
||||
if s3Error != ErrNone {
|
||||
writeErrorResponse(ctx, w, errorCodes.ToAPIErr(s3Error), r.URL)
|
||||
return
|
||||
}
|
||||
globalReplicationStats.Load().incProxy(bucket, removeObjectTaggingAPI, false)
|
||||
// proxy to replication target if active-active replication is in place.
|
||||
perr := proxyTaggingToRepTarget(ctx, bucket, object, nil, opts, proxytgts)
|
||||
if perr.Err != nil {
|
||||
globalReplicationStats.Load().incProxy(bucket, removeObjectTaggingAPI, true)
|
||||
writeErrorResponse(ctx, w, toAPIError(ctx, perr.Err), r.URL)
|
||||
@@ -3915,12 +4030,22 @@ func (api objectAPIHandlers) DeleteObjectTaggingHandler(w http.ResponseWriter, r
|
||||
return
|
||||
}
|
||||
}
|
||||
var s3Error APIErrorCode
|
||||
if isErrObjectNotFound(err) || isErrVersionNotFound(err) {
|
||||
s3Error = authorizeRequestWithExistingTags(ctx, r, policy.DeleteObjectTaggingAction, "")
|
||||
} else {
|
||||
s3Error = authorizeRequestWithUnreadTags(ctx, r, policy.DeleteObjectTaggingAction, err)
|
||||
}
|
||||
if s3Error != ErrNone {
|
||||
writeErrorResponse(ctx, w, errorCodes.ToAPIErr(s3Error), r.URL)
|
||||
return
|
||||
}
|
||||
writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL)
|
||||
return
|
||||
}
|
||||
|
||||
// Allow deleteObjectTagging if policy action is set
|
||||
if s3Error := checkRequestAuthTypeWithExistingTags(ctx, r, policy.DeleteObjectTaggingAction, bucket, object, oi.UserTags); s3Error != ErrNone {
|
||||
if s3Error := authorizeRequestWithExistingTags(ctx, r, policy.DeleteObjectTaggingAction, oi.UserTags); s3Error != ErrNone {
|
||||
writeErrorResponse(ctx, w, errorCodes.ToAPIErr(s3Error), r.URL)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,421 @@
|
||||
// Copyright (c) 2026 Feng Ruohang
|
||||
//
|
||||
// This file is part of Silo Object Storage stack
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/minio/minio/internal/auth"
|
||||
xhttp "github.com/minio/minio/internal/http"
|
||||
)
|
||||
|
||||
func TestPoolLockReductionOrderIndependent(t *testing.T) {
|
||||
const old = objectLockTestStamp0900
|
||||
const recent = objectLockTestStamp1000
|
||||
const newest = objectLockTestStamp1100
|
||||
compliance := objectLockState{mode: "COMPLIANCE", retainUntil: "2035-01-01T00:00:00Z", retentionTimestamp: old}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
states [3]objectLockState
|
||||
want objectLockState
|
||||
}{
|
||||
{"compliance-before-newer-removal", [3]objectLockState{compliance, {retentionTimestamp: recent}, {mode: "GOVERNANCE", retainUntil: "2036-01-01T00:00:00Z", retentionTimestamp: newest}}, compliance},
|
||||
{"longer-compliance-before-newer-shortening", [3]objectLockState{compliance, {mode: "COMPLIANCE", retainUntil: "2034-01-01T00:00:00Z", retentionTimestamp: newest}, {retentionTimestamp: recent}}, compliance},
|
||||
{"unordered-compliance", [3]objectLockState{{mode: "COMPLIANCE", retainUntil: compliance.retainUntil, retentionTimestamp: "invalid"}, {retentionTimestamp: newest}, {}}, objectLockState{mode: "COMPLIANCE", retainUntil: compliance.retainUntil, retentionTimestamp: "invalid"}},
|
||||
{"expired-compliance-can-release", [3]objectLockState{{mode: "COMPLIANCE", retainUntil: "2000-01-01T00:00:00Z", retentionTimestamp: old}, {retentionTimestamp: newest}, {retentionTimestamp: recent}}, objectLockState{retentionTimestamp: newest}},
|
||||
{"governance-can-release", [3]objectLockState{{mode: "GOVERNANCE", retainUntil: compliance.retainUntil, retentionTimestamp: old}, {retentionTimestamp: newest}, {}}, objectLockState{retentionTimestamp: newest}},
|
||||
{"unordered-hold-conflict", [3]objectLockState{{legalHold: "OFF"}, {legalHold: "ON"}, {}}, objectLockState{legalHold: "ON"}},
|
||||
{"newer-hold-release", [3]objectLockState{{legalHold: "ON", legalHoldTimestamp: old}, {legalHold: "OFF", legalHoldTimestamp: newest}, {legalHold: "ON", legalHoldTimestamp: "invalid"}}, objectLockState{legalHold: "OFF", legalHoldTimestamp: newest}},
|
||||
{"independent-fields", [3]objectLockState{compliance, {retentionTimestamp: newest, legalHold: "ON", legalHoldTimestamp: recent}, {legalHold: "OFF", legalHoldTimestamp: old}}, objectLockState{mode: compliance.mode, retainUntil: compliance.retainUntil, retentionTimestamp: old, legalHold: "ON", legalHoldTimestamp: recent}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
for _, order := range [][3]int{{0, 1, 2}, {0, 2, 1}, {1, 0, 2}, {1, 2, 0}, {2, 0, 1}, {2, 1, 0}} {
|
||||
var copies []PoolObjInfo
|
||||
for _, i := range order {
|
||||
meta := make(map[string]string)
|
||||
tc.states[i].restoreRetention(meta)
|
||||
tc.states[i].restoreLegalHold(meta)
|
||||
copies = append(copies, PoolObjInfo{Index: i, ObjInfo: ObjectInfo{UserDefined: meta}})
|
||||
}
|
||||
for name, reduce := range map[string]func([]PoolObjInfo) objectLockState{"merge": mergePoolLockState, "lifecycle": newestLockState} {
|
||||
if got := reduce(copies); got != tc.want {
|
||||
t.Errorf("%s order=%v: got %+v want %+v", name, order, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// All requests copy the same bytes. Revisions belong to the addressed
|
||||
// destination, even if the source is the latest version of the same key.
|
||||
func TestAPICopyReplicaDestinationState(t *testing.T) {
|
||||
defer DetectTestLeak(t)()
|
||||
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
|
||||
t: t, makeBucketOptions: MakeBucketOptions{LockEnabled: true},
|
||||
objAPITest: func(obj ObjectLayer, backend, bucket string, router http.Handler, cred auth.Credentials, t *testing.T) {
|
||||
for _, sameKey := range []bool{true, false} {
|
||||
for _, missing := range []bool{false, true} {
|
||||
for _, directive := range []string{copyDirective, replaceDirective} {
|
||||
name := fmt.Sprintf("copy-destination/%t/%t/%s", sameKey, missing, directive)
|
||||
source := name
|
||||
if !sameKey {
|
||||
source += "-source"
|
||||
}
|
||||
targetMeta := poolLockMetadata("GOVERNANCE", "ON", objectLockTestStamp1000, objectLockTestStamp1000)
|
||||
targetMeta[xhttp.AmzObjectTagging] = "state=destination"
|
||||
targetMeta[r5TagStamp] = objectLockTestStamp1000
|
||||
targetID := mustGetUUID()
|
||||
if !missing {
|
||||
targetID = putObjectLockVersion(t, obj, bucket, name, targetMeta)
|
||||
}
|
||||
sourceMeta := poolLockMetadata("", "OFF", objectLockTestStamp1100, objectLockTestStamp1100)
|
||||
sourceMeta[xhttp.AmzObjectTagging] = "state=source"
|
||||
sourceMeta[r5TagStamp] = objectLockTestStamp1100
|
||||
sourceID := putObjectLockVersion(t, obj, bucket, source, sourceMeta)
|
||||
headers := map[string]string{
|
||||
xhttp.AmzCopySource: "/" + bucket + "/" + source + "?versionId=" + sourceID,
|
||||
xhttp.AmzMetadataDirective: directive, xhttp.AmzTagDirective: replaceDirective,
|
||||
xhttp.MinIOSourceReplicationRequest: "true", xhttp.AmzBucketReplicationStatus: "REPLICA",
|
||||
}
|
||||
// No source revision means no change, including no
|
||||
// inheritance from a different source for a first write.
|
||||
rec := r5Request(t, router, cred, http.MethodPut, "/"+bucket+"/"+name+"?versionId="+targetID, "", headers)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("%s %s: %d %s", backend, name, rec.Code, rec.Body.String())
|
||||
}
|
||||
want := storedObjectLockState(targetMeta)
|
||||
wantTags, wantStamp := "state=destination", objectLockTestStamp1000
|
||||
if missing {
|
||||
want = objectLockState{}
|
||||
wantTags = ""
|
||||
wantStamp = ""
|
||||
}
|
||||
got := r5Stored(t, obj, bucket, name, targetID, wantTags, wantStamp)
|
||||
if state := storedObjectLockState(got.UserDefined); state != want {
|
||||
t.Fatalf("%s absent revision: got %+v want %+v", name, state, want)
|
||||
}
|
||||
// The incoming update is older than the source but may
|
||||
// still win at a new destination. A source lookup loses it.
|
||||
headers[xhttp.MinIOSourceObjectRetentionTimestamp] = objectLockTestStamp0930
|
||||
headers[xhttp.MinIOSourceObjectLegalHoldTimestamp] = objectLockTestStamp0930
|
||||
headers[xhttp.AmzObjectLockMode] = "GOVERNANCE"
|
||||
headers[xhttp.AmzObjectLockRetainUntilDate] = objectLockTestRetainUntil
|
||||
headers[xhttp.AmzObjectLockLegalHold] = "ON"
|
||||
headers[xhttp.MinIOSourceTaggingTimestamp] = objectLockTestStamp0930
|
||||
headers[xhttp.AmzObjectTagging] = "state=incoming"
|
||||
rec = r5Request(t, router, cred, http.MethodPut, "/"+bucket+"/"+name+"?versionId="+targetID, "", headers)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("incoming %s: %d %s", name, rec.Code, rec.Body.String())
|
||||
}
|
||||
if missing {
|
||||
want = objectLockState{mode: "GOVERNANCE", retainUntil: "2030-01-01T00:00:00.000Z", retentionTimestamp: objectLockTestStamp0930, legalHold: "ON", legalHoldTimestamp: objectLockTestStamp0930}
|
||||
wantTags = "state=incoming"
|
||||
wantStamp = objectLockTestStamp0930
|
||||
}
|
||||
got = r5Stored(t, obj, bucket, name, targetID, wantTags, wantStamp)
|
||||
if state := storedObjectLockState(got.UserDefined); state != want {
|
||||
t.Fatalf("%s stale revision: got %+v want %+v", name, state, want)
|
||||
}
|
||||
// Authorized same-content release needs no governance
|
||||
// bypass header; it must not be rejected by the data guard.
|
||||
delete(headers, xhttp.AmzObjectLockMode)
|
||||
delete(headers, xhttp.AmzObjectLockRetainUntilDate)
|
||||
headers[xhttp.MinIOSourceObjectRetentionTimestamp] = "2026-09-03T10:30:00Z"
|
||||
headers[xhttp.MinIOSourceObjectLegalHoldTimestamp] = "2026-09-03T10:30:00Z"
|
||||
headers[xhttp.AmzObjectLockLegalHold] = "OFF"
|
||||
rec = r5Request(t, router, cred, http.MethodPut, "/"+bucket+"/"+name+"?versionId="+targetID, "", headers)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("release %s: %d %s", name, rec.Code, rec.Body.String())
|
||||
}
|
||||
got = r5Stored(t, obj, bucket, name, targetID, wantTags, wantStamp)
|
||||
want = objectLockState{retentionTimestamp: "2026-09-03T10:30:00Z", legalHold: "OFF", legalHoldTimestamp: "2026-09-03T10:30:00Z"}
|
||||
if state := storedObjectLockState(got.UserDefined); state != want {
|
||||
t.Fatalf("%s release: got %+v want %+v", name, state, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestReplicaReferenceCopyDestinationState(t *testing.T) {
|
||||
z, bucket := consistencyPools(t)
|
||||
for _, missing := range []bool{false, true} {
|
||||
key := fmt.Sprintf("reference-copy-%t", missing)
|
||||
id := mustGetUUID()
|
||||
targetMeta := poolLockMetadata("GOVERNANCE", "ON", objectLockTestStamp1000, objectLockTestStamp1000)
|
||||
targetMeta[xhttp.AmzObjectTagging] = "state=destination"
|
||||
targetMeta[r5TagStamp] = objectLockTestStamp1000
|
||||
if !missing {
|
||||
putConsistencyObject(t, z, bucket, key, 0, "data", ObjectOptions{Versioned: true, VersionID: id, UserDefined: targetMeta})
|
||||
}
|
||||
sourceMeta := poolLockMetadata("", "OFF", objectLockTestStamp1100, objectLockTestStamp1100)
|
||||
sourceMeta[xhttp.AmzObjectTagging] = "state=source"
|
||||
sourceMeta[r5TagStamp] = objectLockTestStamp1100
|
||||
src := putConsistencyObject(t, z, bucket, key, 0, "data", ObjectOptions{Versioned: true, UserDefined: sourceMeta})
|
||||
src.metadataOnly = true
|
||||
src.UserDefined = make(map[string]string)
|
||||
_, err := z.CopyObject(t.Context(), bucket, key, bucket, key, src, ObjectOptions{VersionID: src.VersionID}, ObjectOptions{Versioned: true, VersionID: id, ReplicaLockReconcile: true, ReplicationRequest: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := storedObjectLockState(targetMeta)
|
||||
wantTags, wantStamp := "state=destination", objectLockTestStamp1000
|
||||
if missing {
|
||||
want = objectLockState{}
|
||||
wantTags = ""
|
||||
wantStamp = ""
|
||||
}
|
||||
got := r5Stored(t, z, bucket, key, id, wantTags, wantStamp)
|
||||
if state := storedObjectLockState(got.UserDefined); state != want {
|
||||
t.Fatalf("missing=%t: got %+v want %+v", missing, state, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The set must not undo the all-pools decision with a newer weak revision
|
||||
// from its own copy before the protected competing copy is retired.
|
||||
func TestPoolsReplicaComplianceMetadataCopyOrder(t *testing.T) {
|
||||
z, bucket := consistencyPools(t)
|
||||
for _, protectedPool := range []int{0, 1} {
|
||||
key := fmt.Sprintf("compliance-copy-%d", protectedPool)
|
||||
id := mustGetUUID()
|
||||
stamp := UTCNow().Add(-time.Hour)
|
||||
var source ObjectInfo
|
||||
for pool := range 2 {
|
||||
meta := poolLockMetadata("", "OFF", objectLockTestStamp1100, objectLockTestStamp1000)
|
||||
if pool == protectedPool {
|
||||
meta = poolLockMetadata("COMPLIANCE", "OFF", objectLockTestStamp0900, objectLockTestStamp1000)
|
||||
}
|
||||
oi := putConsistencyObject(t, z, bucket, key, pool, "data", ObjectOptions{Versioned: true, VersionID: id, MTime: stamp, UserDefined: meta})
|
||||
if pool == 0 {
|
||||
source = oi
|
||||
}
|
||||
}
|
||||
source.metadataOnly = true
|
||||
source.UserDefined = map[string]string{}
|
||||
_, err := z.CopyObject(t.Context(), bucket, key, bucket, key, source, ObjectOptions{VersionID: id}, ObjectOptions{VersionID: id, Versioned: true, ReplicaLockReconcile: true, ReplicationRequest: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := readObjectLockFields(t, z, bucket, key, id)
|
||||
if got.mode != "COMPLIANCE" || got.retainUntil != "2030-01-01T00:00:00Z" {
|
||||
t.Fatalf("protected pool %d lost COMPLIANCE: %+v", protectedPool, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIReplicaRetentionTimestampAuthorization(t *testing.T) {
|
||||
defer DetectTestLeak(t)()
|
||||
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
|
||||
t: t, makeBucketOptions: MakeBucketOptions{LockEnabled: true},
|
||||
objAPITest: func(obj ObjectLayer, backend, bucket string, router http.Handler, _ auth.Credentials, t *testing.T) {
|
||||
basic := `"s3:GetObject","s3:PutObject","s3:ReplicateObject"`
|
||||
noRetention := newObjectAttributesAuthzUser(t, backend, bucket, basic)
|
||||
withRetention := newObjectAttributesAuthzUser(t, backend, bucket, basic+`,"s3:PutObjectRetention"`)
|
||||
for _, operation := range []string{"put", "copy", "multipart"} {
|
||||
for _, authorized := range []bool{false, true} {
|
||||
for _, timestamp := range []bool{false, true} {
|
||||
key := fmt.Sprintf("replica-permission/%s/%t/%t", operation, authorized, timestamp)
|
||||
meta := poolLockMetadata("GOVERNANCE", "", objectLockTestStamp0900, "")
|
||||
id := putObjectLockVersion(t, obj, bucket, key, meta)
|
||||
headers := map[string]string{xhttp.MinIOSourceReplicationRequest: "true", xhttp.AmzBucketReplicationStatus: "REPLICA"}
|
||||
if timestamp {
|
||||
headers[xhttp.MinIOSourceObjectRetentionTimestamp] = objectLockTestStamp1000
|
||||
}
|
||||
method, path, body := http.MethodPut, "/"+bucket+"/"+key+"?versionId="+id, "data"
|
||||
switch operation {
|
||||
case "copy":
|
||||
headers[xhttp.AmzCopySource] = "/" + bucket + "/" + key + "?versionId=" + id
|
||||
headers[xhttp.AmzMetadataDirective] = replaceDirective
|
||||
body = ""
|
||||
case "multipart":
|
||||
method = http.MethodPost
|
||||
path += "&uploads"
|
||||
body = ""
|
||||
}
|
||||
cred := noRetention
|
||||
if authorized {
|
||||
cred = withRetention
|
||||
}
|
||||
rec := r5Request(t, router, cred, method, path, body, headers)
|
||||
want := http.StatusOK
|
||||
if timestamp && !authorized {
|
||||
want = http.StatusForbidden
|
||||
}
|
||||
if rec.Code != want {
|
||||
t.Fatalf("%s: %d want %d: %s", key, rec.Code, want, rec.Body.String())
|
||||
}
|
||||
if want == http.StatusForbidden || !timestamp {
|
||||
got := readObjectLockFields(t, obj, bucket, key, id)
|
||||
if got.mode != "GOVERNANCE" || got.retentionStamp != objectLockTestStamp0900 {
|
||||
t.Fatalf("unrequested/denied retention changed: %+v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestAPILocalObjectLockTimestampsAdvance(t *testing.T) {
|
||||
defer DetectTestLeak(t)()
|
||||
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
|
||||
t: t, makeBucketOptions: MakeBucketOptions{LockEnabled: true},
|
||||
objAPITest: func(obj ObjectLayer, backend, bucket string, router http.Handler, cred auth.Credentials, t *testing.T) {
|
||||
future := UTCNow().Add(48 * time.Hour)
|
||||
meta := poolLockMetadata("GOVERNANCE", "ON", future.Format(time.RFC3339Nano), future.Format(time.RFC3339Nano))
|
||||
id := putObjectLockVersion(t, obj, bucket, "local-lock", meta)
|
||||
for n := 1; n <= 2; n++ {
|
||||
if status := putRetention(t, router, cred, bucket, "local-lock", id, "GOVERNANCE", time.Date(2035, 1, n, 0, 0, 0, 0, time.UTC), false); status != http.StatusOK {
|
||||
t.Fatalf("%s retention: %d", backend, status)
|
||||
}
|
||||
body := []byte(`<LegalHold xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Status>OFF</Status></LegalHold>`)
|
||||
sum := md5.Sum(body)
|
||||
rec := retentionAuthzRequest(t, router, cred, http.MethodPut, makeTestTargetURL("", bucket, "local-lock", url.Values{"legal-hold": {""}, "versionId": {id}}), body, map[string]string{xhttp.ContentMD5: base64.StdEncoding.EncodeToString(sum[:])})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("%s hold: %d %s", backend, rec.Code, rec.Body.String())
|
||||
}
|
||||
got := readObjectLockFields(t, obj, bucket, "local-lock", id)
|
||||
want := future.Add(time.Duration(n) * time.Nanosecond).Format(time.RFC3339Nano)
|
||||
if got.retentionStamp != want || got.legalHoldStamp != want {
|
||||
t.Fatalf("local mutation %d regressed: %+v want %s", n, got, want)
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
type lockBoundaryReadFailure struct {
|
||||
ObjectLayer
|
||||
bucket, key, version string
|
||||
metadataError error
|
||||
destinationError error
|
||||
deleteCalls int
|
||||
}
|
||||
|
||||
func (f *lockBoundaryReadFailure) GetObjectNInfo(ctx context.Context, bucket, key string, rs *HTTPRangeSpec, h http.Header, opts ObjectOptions) (*GetObjectReader, error) {
|
||||
if f.metadataError != nil && bucket == minioMetaBucket && strings.HasPrefix(key, pathJoin(bucketMetaPrefix, f.bucket)+"/") {
|
||||
return nil, f.metadataError
|
||||
}
|
||||
return f.ObjectLayer.GetObjectNInfo(ctx, bucket, key, rs, h, opts)
|
||||
}
|
||||
|
||||
func (f *lockBoundaryReadFailure) GetObjectInfo(ctx context.Context, bucket, key string, opts ObjectOptions) (ObjectInfo, error) {
|
||||
if f.destinationError != nil && bucket == f.bucket && key == f.key && opts.VersionID == f.version {
|
||||
return ObjectInfo{}, f.destinationError
|
||||
}
|
||||
return f.ObjectLayer.GetObjectInfo(ctx, bucket, key, opts)
|
||||
}
|
||||
|
||||
func (f *lockBoundaryReadFailure) DeleteObject(ctx context.Context, bucket, key string, opts ObjectOptions) (ObjectInfo, error) {
|
||||
f.deleteCalls++
|
||||
return f.ObjectLayer.DeleteObject(ctx, bucket, key, opts)
|
||||
}
|
||||
|
||||
func TestAPICopyReplicaDestinationReadFailure(t *testing.T) {
|
||||
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{t: t, makeBucketOptions: MakeBucketOptions{LockEnabled: true}, objAPITest: func(obj ObjectLayer, backend, bucket string, _ http.Handler, cred auth.Credentials, t *testing.T) {
|
||||
id := putObjectLockVersion(t, obj, bucket, "destination", poolLockMetadata("GOVERNANCE", "ON", objectLockTestStamp1000, objectLockTestStamp1000))
|
||||
source := putObjectLockVersion(t, obj, bucket, "source", nil)
|
||||
f := &lockBoundaryReadFailure{ObjectLayer: obj, bucket: bucket, key: "destination", version: id, destinationError: errErasureReadQuorum}
|
||||
router := initTestAPIEndPoints(f, []string{"CopyObject"})
|
||||
headers := map[string]string{xhttp.AmzCopySource: "/" + bucket + "/source?versionId=" + source, xhttp.MinIOSourceReplicationRequest: "true", xhttp.AmzBucketReplicationStatus: "REPLICA"}
|
||||
rec := r5Request(t, router, cred, http.MethodPut, "/"+bucket+"/destination?versionId="+id, "", headers)
|
||||
if rec.Code < 500 {
|
||||
t.Fatalf("%s read failure allowed copy: %d %s", backend, rec.Code, rec.Body.String())
|
||||
}
|
||||
if got := readObjectLockFields(t, obj, bucket, "destination", id); got.legalHold != "ON" || got.mode != "GOVERNANCE" {
|
||||
t.Fatalf("failed read changed state: %+v", got)
|
||||
}
|
||||
}})
|
||||
}
|
||||
|
||||
func TestAPIForceDeleteLockConfigFailure(t *testing.T) {
|
||||
for _, locked := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("locked=%t", locked), func(t *testing.T) {
|
||||
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{t: t, makeBucketOptions: MakeBucketOptions{LockEnabled: locked}, objAPITest: func(obj ObjectLayer, backend, bucket string, _ http.Handler, cred auth.Credentials, t *testing.T) {
|
||||
saved, err := globalBucketMetadataSys.Get(bucket)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
restoreInit := markBucketMetadataInitialized(t)
|
||||
defer restoreInit()
|
||||
previous := newObjectLayerFn()
|
||||
defer setObjectLayer(previous)
|
||||
defer globalBucketMetadataSys.Set(bucket, saved)
|
||||
for _, failure := range []string{"quorum", "unavailable", "cache-miss"} {
|
||||
key := "force-" + failure + "/data"
|
||||
putObjectLockVersion(t, obj, bucket, key, nil)
|
||||
f := &lockBoundaryReadFailure{ObjectLayer: obj, bucket: bucket}
|
||||
globalBucketMetadataSys.Remove(bucket)
|
||||
switch failure {
|
||||
case "quorum":
|
||||
f.metadataError = errErasureReadQuorum
|
||||
case "unavailable":
|
||||
f.metadataError = errServerNotInitialized
|
||||
}
|
||||
router := initTestAPIEndPoints(f, []string{"DeleteObject"})
|
||||
rec := r5Request(t, router, cred, http.MethodDelete, "/"+bucket+"/force-"+failure, "", map[string]string{xhttp.MinIOForceDelete: "true"})
|
||||
setObjectLayer(previous)
|
||||
globalBucketMetadataSys.Set(bucket, saved)
|
||||
allowed := failure == "cache-miss" && !locked
|
||||
if allowed {
|
||||
if rec.Code != http.StatusNoContent || f.deleteCalls != 1 {
|
||||
t.Fatalf("%s missing unlocked cache: %d calls=%d %s", backend, rec.Code, f.deleteCalls, rec.Body.String())
|
||||
}
|
||||
} else {
|
||||
if rec.Code < 400 || f.deleteCalls != 0 {
|
||||
t.Fatalf("%s %s: force-delete proceeded: %d calls=%d %s", backend, failure, rec.Code, f.deleteCalls, rec.Body.String())
|
||||
}
|
||||
if _, err := obj.GetObjectInfo(t.Context(), bucket, key, ObjectOptions{}); err != nil {
|
||||
t.Fatalf("rejected force-delete lost data: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPILocalCopyTagTimestampAdvances(t *testing.T) {
|
||||
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{t: t, objAPITest: func(obj ObjectLayer, backend, bucket string, router http.Handler, cred auth.Credentials, t *testing.T) {
|
||||
stamp := UTCNow().Add(48 * time.Hour)
|
||||
meta := map[string]string{r5TagStamp: stamp.Format(time.RFC3339Nano), xhttp.AmzObjectTagging: "key=old"}
|
||||
_, err := obj.PutObject(t.Context(), bucket, "local-copy", mustGetPutObjReader(t, strings.NewReader("data"), 4, "", ""), ObjectOptions{UserDefined: maps.Clone(meta)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := r5Request(t, router, cred, http.MethodPut, "/"+bucket+"/local-copy", "", map[string]string{xhttp.AmzCopySource: "/" + bucket + "/local-copy", xhttp.AmzMetadataDirective: replaceDirective, xhttp.AmzTagDirective: replaceDirective})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("%s copy: %d %s", backend, rec.Code, rec.Body.String())
|
||||
}
|
||||
r5Stored(t, obj, bucket, "local-copy", "", "", stamp.Add(time.Nanosecond).Format(time.RFC3339Nano))
|
||||
}})
|
||||
}
|
||||
@@ -228,7 +228,7 @@ func (client *peerRESTClient) GetMemInfo(ctx context.Context) (info madmin.MemIn
|
||||
}
|
||||
|
||||
// GetMetrics - fetch metrics from a remote node.
|
||||
func (client *peerRESTClient) GetMetrics(ctx context.Context, t madmin.MetricType, opts collectMetricsOpts) (info madmin.RealtimeMetrics, err error) {
|
||||
func (client *peerRESTClient) GetMetrics(ctx context.Context, t madmin.MetricType, opts collectMetricsOpts) (info realtimeMetrics, err error) {
|
||||
values := make(url.Values)
|
||||
values.Set(peerRESTMetricsTypes, strconv.FormatUint(uint64(t), 10))
|
||||
for disk := range opts.disks {
|
||||
|
||||
@@ -62,7 +62,7 @@ var (
|
||||
madminOSInfo = grid.NewJSONPool[madmin.OSInfo]()
|
||||
madminPartitions = grid.NewJSONPool[madmin.Partitions]()
|
||||
madminProcInfo = grid.NewJSONPool[madmin.ProcInfo]()
|
||||
madminRealtimeMetrics = grid.NewJSONPool[madmin.RealtimeMetrics]()
|
||||
madminRealtimeMetrics = grid.NewJSONPool[realtimeMetrics]()
|
||||
madminServerProperties = grid.NewJSONPool[madmin.ServerProperties]()
|
||||
madminStorageInfo = grid.NewJSONPool[madmin.StorageInfo]()
|
||||
madminSysConfig = grid.NewJSONPool[madmin.SysConfig]()
|
||||
@@ -84,7 +84,7 @@ var (
|
||||
getLocksRPC = grid.NewSingleHandler[*grid.MSS, *localLockMap](grid.HandlerGetLocks, grid.NewMSS, func() *localLockMap { return &localLockMap{} })
|
||||
getMemInfoRPC = grid.NewSingleHandler[*grid.MSS, *grid.JSON[madmin.MemInfo]](grid.HandlerGetMemInfo, grid.NewMSS, madminMemInfo.NewJSON)
|
||||
getMetacacheListingRPC = grid.NewSingleHandler[*listPathOptions, *metacache](grid.HandlerGetMetacacheListing, func() *listPathOptions { return &listPathOptions{} }, func() *metacache { return &metacache{} })
|
||||
getMetricsRPC = grid.NewSingleHandler[*grid.URLValues, *grid.JSON[madmin.RealtimeMetrics]](grid.HandlerGetMetrics, grid.NewURLValues, madminRealtimeMetrics.NewJSON)
|
||||
getMetricsRPC = grid.NewSingleHandler[*grid.URLValues, *grid.JSON[realtimeMetrics]](grid.HandlerGetMetrics, grid.NewURLValues, madminRealtimeMetrics.NewJSON)
|
||||
getNetInfoRPC = grid.NewSingleHandler[*grid.MSS, *grid.JSON[madmin.NetInfo]](grid.HandlerGetNetInfo, grid.NewMSS, madminNetInfo.NewJSON)
|
||||
getOSInfoRPC = grid.NewSingleHandler[*grid.MSS, *grid.JSON[madmin.OSInfo]](grid.HandlerGetOSInfo, grid.NewMSS, madminOSInfo.NewJSON)
|
||||
getPartitionsRPC = grid.NewSingleHandler[*grid.MSS, *grid.JSON[madmin.Partitions]](grid.HandlerGetPartitions, grid.NewMSS, madminPartitions.NewJSON)
|
||||
@@ -423,7 +423,7 @@ func (s *peerRESTServer) GetMemInfoHandler(_ *grid.MSS) (*grid.JSON[madmin.MemIn
|
||||
}
|
||||
|
||||
// GetMetricsHandler - returns server metrics.
|
||||
func (s *peerRESTServer) GetMetricsHandler(v *grid.URLValues) (*grid.JSON[madmin.RealtimeMetrics], *grid.RemoteErr) {
|
||||
func (s *peerRESTServer) GetMetricsHandler(v *grid.URLValues) (*grid.JSON[realtimeMetrics], *grid.RemoteErr) {
|
||||
values := v.Values()
|
||||
var types madmin.MetricType
|
||||
if t, _ := strconv.ParseUint(values.Get(peerRESTMetricsTypes), 10, 64); t != 0 {
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
// Copyright (c) 2026 Feng Ruohang
|
||||
//
|
||||
// This file is part of Silo Object Storage stack
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/minio/minio/internal/crypto"
|
||||
"github.com/minio/minio/internal/hash/sha256"
|
||||
xioutil "github.com/minio/minio/internal/ioutil"
|
||||
)
|
||||
|
||||
type replicaContentGuardFn func(context.Context, string, string, ObjectInfo, func() (*GetObjectReader, error)) error
|
||||
|
||||
// verifyLockedReplica compares the actual data, never a sender's ETag or
|
||||
// checksum assertion. A peer may retransmit a protected version to update its
|
||||
// metadata, but may not replace its content. Call under the object write lock.
|
||||
func verifyLockedReplica(ctx context.Context, incoming, stored ObjectInfo, openIncoming, openStored func() (*GetObjectReader, error)) error {
|
||||
if !enforceRetentionForDeletion(ctx, stored) {
|
||||
return nil
|
||||
}
|
||||
locked := ObjectLocked{Bucket: stored.Bucket, Object: stored.Name, VersionID: stored.VersionID}
|
||||
if storedObjectLockState(stored.UserDefined).complianceWouldWeaken(storedObjectLockState(incoming.UserDefined)) {
|
||||
return locked
|
||||
}
|
||||
inSSEC, oldSSEC := crypto.SSEC.IsEncrypted(incoming.UserDefined), crypto.SSEC.IsEncrypted(stored.UserDefined)
|
||||
if inSSEC != oldSSEC {
|
||||
return locked
|
||||
}
|
||||
if inSSEC {
|
||||
inSize, inErr := incoming.GetActualSize()
|
||||
oldSize, oldErr := stored.GetActualSize()
|
||||
if inErr != nil || oldErr != nil || inSize != oldSize {
|
||||
return locked
|
||||
}
|
||||
// Without the customer key, compare ciphertext and its interpretation.
|
||||
// Equal ciphertext with a changed seal or multipart framing is not the
|
||||
// same object, even if the supplied ETag and size are unchanged.
|
||||
for _, key := range []string{
|
||||
crypto.MetaIV, crypto.MetaAlgorithm, crypto.MetaSealedKeySSEC,
|
||||
ReservedMetadataPrefix + "compression",
|
||||
} {
|
||||
if incoming.UserDefined[key] != stored.UserDefined[key] {
|
||||
return locked
|
||||
}
|
||||
}
|
||||
if crypto.IsMultiPart(incoming.UserDefined) != crypto.IsMultiPart(stored.UserDefined) || incoming.isMultipart() != stored.isMultipart() {
|
||||
return locked
|
||||
}
|
||||
if crypto.IsMultiPart(incoming.UserDefined) {
|
||||
if len(incoming.Parts) != len(stored.Parts) {
|
||||
return locked
|
||||
}
|
||||
for i, p := range incoming.Parts {
|
||||
old := stored.Parts[i]
|
||||
if p.Number != old.Number || p.Size != old.Size {
|
||||
return locked
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
in, err := openIncoming()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
old, err := openStored()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer old.Close()
|
||||
ih, oh := sha256.New(), sha256.New()
|
||||
inSize, err := io.Copy(ih, in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
oldSize, err := io.Copy(oh, old)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if inSize != oldSize || !bytes.Equal(ih.Sum(nil), oh.Sum(nil)) {
|
||||
return locked
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (er erasureObjects) guardReplicaContent(ctx context.Context, bucket, object string, incoming ObjectInfo, openIncoming func() (*GetObjectReader, error)) error {
|
||||
opts := ObjectOptions{VersionID: incoming.VersionID, NoLock: true, NoAuditLog: true, ReplicationRequest: true}
|
||||
if opts.VersionID == "" {
|
||||
opts.VersionID = nullVersionID
|
||||
}
|
||||
stored, err := er.getObjectInfo(ctx, bucket, object, opts)
|
||||
if isErrObjectNotFound(err) || isErrVersionNotFound(err) || isErrMethodNotAllowed(err) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return verifyLockedReplica(ctx, incoming, stored, openIncoming, func() (*GetObjectReader, error) {
|
||||
return er.GetObjectNInfo(ctx, bucket, object, nil, http.Header{}, opts)
|
||||
})
|
||||
}
|
||||
|
||||
func (z *erasureServerPools) guardReplicaContent(ctx context.Context, bucket, object string, incoming ObjectInfo, openIncoming func() (*GetObjectReader, error)) error {
|
||||
opts := ObjectOptions{VersionID: incoming.VersionID, NoLock: true, NoAuditLog: true, ReplicationRequest: true}
|
||||
if opts.VersionID == "" {
|
||||
opts.VersionID = nullVersionID
|
||||
}
|
||||
copies, err := z.objectPoolInfos(ctx, bucket, object, opts)
|
||||
if isErrObjectNotFound(err) || isErrVersionNotFound(err) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, copy := range copies {
|
||||
if err := verifyLockedReplica(ctx, incoming, copy.ObjInfo, openIncoming, func() (*GetObjectReader, error) {
|
||||
return z.serverPools[copy.Index].GetObjectNInfo(ctx, bucket, object, nil, http.Header{}, opts)
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkReplicaContent reads the staged version before rename commits it. Its
|
||||
// data is decoded/decrypted just like a GET (raw ciphertext for SSE-C), so this
|
||||
// covers multipart, compression and destination SSE-S3/KMS re-encryption.
|
||||
// disks and metadata are in erasure shard order, as passed to renameData.
|
||||
func (er erasureObjects) checkReplicaContent(ctx context.Context, bucket, object, stageBucket, stageObject string, metadata []FileInfo, disks []StorageAPI, opts ObjectOptions) error {
|
||||
if (!opts.ReplicaLockReconcile && !opts.ReplicationRequest) || opts.DataMovement {
|
||||
return nil
|
||||
}
|
||||
var fi FileInfo
|
||||
for i, disk := range disks {
|
||||
if disk != nil {
|
||||
fi = metadata[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
incoming := fi.ToObjectInfo(bucket, object, opts.Versioned || opts.VersionSuspended)
|
||||
guard := opts.replicaContentGuard
|
||||
if guard == nil {
|
||||
guard = er.guardReplicaContent
|
||||
}
|
||||
return guard(ctx, bucket, object, incoming, func() (*GetObjectReader, error) {
|
||||
// A marker-only peer write does not reconcile the stored protection.
|
||||
// It therefore cannot replace a protected version, even with equal data.
|
||||
if !opts.ReplicaLockReconcile {
|
||||
return nil, ObjectLocked{Bucket: bucket, Object: decodeDirObject(object), VersionID: incoming.VersionID}
|
||||
}
|
||||
if incoming.IsCompressed() {
|
||||
// A replica may assert the source's logical size. A compressed GET
|
||||
// limits its output to that size, so verify it against the lengths
|
||||
// recorded while consuming the uploaded parts before comparing GETs.
|
||||
// Otherwise a forged zero size could hide a non-empty replacement.
|
||||
var actual int64
|
||||
for _, part := range incoming.Parts {
|
||||
actual += part.ActualSize
|
||||
}
|
||||
declared, err := incoming.GetActualSize()
|
||||
if err != nil || declared != actual {
|
||||
return nil, ObjectLocked{Bucket: bucket, Object: decodeDirObject(object), VersionID: incoming.VersionID}
|
||||
}
|
||||
}
|
||||
readOpts := ObjectOptions{NoDecryption: crypto.SSEC.IsEncrypted(incoming.UserDefined)}
|
||||
fn, off, length, err := NewGetObjectReader(nil, incoming, readOpts, http.Header{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// The ordinary reader expects original drive order, not shard order.
|
||||
readDisks, readMeta := make([]StorageAPI, len(disks)), make([]FileInfo, len(metadata))
|
||||
for i, shard := range fi.Erasure.Distribution {
|
||||
readDisks[i], readMeta[i] = disks[shard-1], metadata[shard-1]
|
||||
readMeta[i].Erasure.Index = shard
|
||||
}
|
||||
pr, pw := xioutil.WaitPipe()
|
||||
go func() {
|
||||
pw.CloseWithError(er.getObjectWithFileInfo(ctx, stageBucket, stageObject, off, length, pw, fi, readMeta, readDisks))
|
||||
}()
|
||||
reader, err := fn(pr, http.Header{}, func() { pr.CloseWithError(nil) })
|
||||
if err != nil {
|
||||
pr.CloseWithError(err)
|
||||
}
|
||||
return reader, err
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
// Copyright (c) 2026 Feng Ruohang
|
||||
//
|
||||
// This file is part of Silo Object Storage stack
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/minio/minio/internal/auth"
|
||||
"github.com/minio/minio/internal/crypto"
|
||||
xhttp "github.com/minio/minio/internal/http"
|
||||
"github.com/minio/minio/internal/kms"
|
||||
)
|
||||
|
||||
func TestAPIReplicaPreservesProtectedContent(t *testing.T) {
|
||||
defer DetectTestLeak(t)()
|
||||
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
|
||||
t: t, makeBucketOptions: MakeBucketOptions{LockEnabled: true},
|
||||
objAPITest: func(obj ObjectLayer, backend, bucket string, router http.Handler, cred auth.Credentials, t *testing.T) {
|
||||
oldTLS, oldKMS := globalIsTLS, GlobalKMS
|
||||
globalIsTLS, GlobalKMS = true, kms.NewStub("replica-content")
|
||||
defer func() { globalIsTLS, GlobalKMS = oldTLS, oldKMS }()
|
||||
restoreCompression := disableCompression()
|
||||
defer restoreCompression()
|
||||
request := func(method, url string, data []byte, headers map[string]string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req, err := newTestSignedRequestV4(method, url, int64(len(data)), bytes.NewReader(data), cred.AccessKey, cred.SecretKey, headers)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
for _, encryption := range []string{"plain", "sse-s3", "sse-kms", "sse-c", "plain-compressed", "sse-s3-compressed"} {
|
||||
restore := func() {}
|
||||
if strings.HasSuffix(encryption, "-compressed") {
|
||||
restore = setCopyChecksumCompression(true)
|
||||
}
|
||||
for _, multipart := range []bool{false, true} {
|
||||
for _, protection := range []string{"COMPLIANCE", "LEGALHOLD", "GOVERNANCE"} {
|
||||
key := fmt.Sprintf("protected/%s/%v/%s.txt", encryption, multipart, protection)
|
||||
enc := map[string]string{}
|
||||
switch encryption {
|
||||
case "sse-s3", "sse-s3-compressed":
|
||||
enc[xhttp.AmzServerSideEncryption] = xhttp.AmzEncryptionAES
|
||||
case "sse-kms":
|
||||
enc[xhttp.AmzServerSideEncryption] = xhttp.AmzEncryptionKMS
|
||||
enc[xhttp.AmzServerSideEncryptionKmsID] = "replica-content"
|
||||
case "sse-c":
|
||||
enc = ssecKeyHeaders(bytes.Repeat([]byte{0x37}, 32), false)
|
||||
}
|
||||
headers := maps.Clone(enc)
|
||||
maps.Copy(headers, batchLockMeta(protection))
|
||||
// Both inline and disk-backed writes are exercised.
|
||||
body := bytes.Repeat([]byte("immutable content "), 20000)
|
||||
switch protection {
|
||||
case "LEGALHOLD":
|
||||
body = body[:64]
|
||||
case "GOVERNANCE":
|
||||
body = nil
|
||||
}
|
||||
if !multipart {
|
||||
putCopyChecksumSource(t, router, cred, bucket, key, body, headers)
|
||||
} else {
|
||||
initRec := request(http.MethodPost, getNewMultipartURL("", bucket, key), nil, headers)
|
||||
if initRec.Code != http.StatusOK {
|
||||
t.Fatalf("source initiate: %d %s", initRec.Code, initRec.Body.String())
|
||||
}
|
||||
var init InitiateMultipartUploadResponse
|
||||
if err := xml.Unmarshal(initRec.Body.Bytes(), &init); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
part := request(http.MethodPut, getPutObjectPartURL("", bucket, key, init.UploadID, "1"), body, enc)
|
||||
if part.Code != http.StatusOK {
|
||||
t.Fatalf("source part: %d %s", part.Code, part.Body.String())
|
||||
}
|
||||
complete, err := xml.Marshal(CompleteMultipartUpload{Parts: []CompletePart{{PartNumber: 1, ETag: canonicalizeETag(part.Header()[xhttp.ETag][0])}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := request(http.MethodPost, getCompleteMultipartUploadURL("", bucket, key, init.UploadID), complete, enc)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("source complete: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
gr, err := obj.GetObjectNInfo(t.Context(), bucket, key, nil, http.Header{}, ObjectOptions{ReplicationRequest: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
src := gr.ObjInfo
|
||||
wire, err := io.ReadAll(gr)
|
||||
gr.Close()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, altered := range []bool{true, false} {
|
||||
data := bytes.Clone(wire)
|
||||
if altered {
|
||||
if len(data) == 0 {
|
||||
data = []byte("x")
|
||||
} else {
|
||||
data[len(data)-1] ^= 0x20
|
||||
}
|
||||
}
|
||||
opts, _, err := putReplicationOpts(t.Context(), "", src)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hdrs := map[string]string{}
|
||||
for k, vs := range opts.Header() {
|
||||
if len(vs) > 0 {
|
||||
hdrs[k] = vs[0]
|
||||
}
|
||||
}
|
||||
hdrs[xhttp.MinIOSourceReplicationRequest] = "true"
|
||||
hdrs[xhttp.AmzBucketReplicationStatus] = "REPLICA"
|
||||
// Forge the old ETag and size along with a newer tag revision:
|
||||
// the body comparison must still reject different bytes.
|
||||
hdrs[xhttp.MinIOSourceETag] = src.ETag
|
||||
hdrs[xhttp.MinIOSourceTaggingTimestamp] = UTCNow().Add(time.Minute).Format(time.RFC3339Nano)
|
||||
hdrs[xhttp.AmzObjectTagging] = "replica=updated"
|
||||
var rec *httptest.ResponseRecorder
|
||||
if !multipart {
|
||||
rec = request(http.MethodPut, getPutObjectURL("", bucket, key)+"?versionId="+src.VersionID, data, hdrs)
|
||||
} else {
|
||||
initRec := request(http.MethodPost, getNewMultipartURL("", bucket, key)+"&versionId="+src.VersionID, nil, hdrs)
|
||||
if initRec.Code != http.StatusOK {
|
||||
t.Fatalf("%s %s initiate: %d %s", backend, key, initRec.Code, initRec.Body.String())
|
||||
}
|
||||
var init InitiateMultipartUploadResponse
|
||||
if err := xml.Unmarshal(initRec.Body.Bytes(), &init); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
partHdrs := map[string]string{xhttp.MinIOSourceReplicationRequest: "true"}
|
||||
partRec := request(http.MethodPut, getPutObjectPartURL("", bucket, key, init.UploadID, "1"), data, partHdrs)
|
||||
if partRec.Code != http.StatusOK && (!altered || !strings.Contains(partRec.Body.String(), "XMinioObjectTampered")) {
|
||||
t.Fatalf("%s %s part: %d %s", backend, key, partRec.Code, partRec.Body.String())
|
||||
}
|
||||
if partRec.Code != http.StatusOK {
|
||||
rec = partRec
|
||||
} else {
|
||||
complete, err := xml.Marshal(CompleteMultipartUpload{Parts: []CompletePart{{PartNumber: 1, ETag: canonicalizeETag(partRec.Header()[xhttp.ETag][0])}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
actual, err := src.GetActualSize()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
partHdrs[xhttp.MinIOReplicationActualObjectSize] = strconv.FormatInt(actual, 10)
|
||||
rec = request(http.MethodPost, getCompleteMultipartUploadURL("", bucket, key, init.UploadID), complete, partHdrs)
|
||||
}
|
||||
}
|
||||
if altered {
|
||||
if rec.Code < 400 || !strings.Contains(rec.Body.String(), "WORM") && !strings.Contains(rec.Body.String(), "XMinioObjectTampered") {
|
||||
t.Fatalf("%s %s: different content not refused: %d %s", backend, key, rec.Code, rec.Body.String())
|
||||
}
|
||||
} else if rec.Code != http.StatusOK {
|
||||
t.Fatalf("%s %s: same content refused: %d %s", backend, key, rec.Code, rec.Body.String())
|
||||
}
|
||||
getHdrs := map[string]string{}
|
||||
if encryption == "sse-c" {
|
||||
getHdrs = enc
|
||||
}
|
||||
got := request(http.MethodGet, getGetObjectURL("", bucket, key)+"?versionId="+src.VersionID, nil, getHdrs)
|
||||
if got.Code != http.StatusOK || !bytes.Equal(got.Body.Bytes(), body) {
|
||||
t.Fatalf("%s %s: protected content changed or became unreadable: %d", backend, key, got.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
restore()
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestPoolsReplicaProtectedContentAcrossPools(t *testing.T) {
|
||||
z, bucket := consistencyPools(t)
|
||||
const key = "protected-across-pools"
|
||||
id := mustGetUUID()
|
||||
stamp := UTCNow().Add(-time.Hour)
|
||||
putConsistencyObject(t, z, bucket, key, 0, "original", ObjectOptions{Versioned: true, VersionID: id, MTime: stamp})
|
||||
putConsistencyObject(t, z, bucket, key, 1, "original", ObjectOptions{Versioned: true, VersionID: id, MTime: stamp, UserDefined: batchLockMeta("COMPLIANCE")})
|
||||
_, err := z.PutObject(t.Context(), bucket, key, mustGetPutObjReader(t, strings.NewReader("tampered"), 8, "", ""), ObjectOptions{Versioned: true, VersionID: id, ReplicationRequest: true, ReplicaLockReconcile: true, UserDefined: map[string]string{}})
|
||||
if _, ok := err.(ObjectLocked); !ok {
|
||||
t.Fatalf("protected pool copy allowed overwrite: %v", err)
|
||||
}
|
||||
for i, pool := range z.serverPools {
|
||||
gr, err := pool.GetObjectNInfo(t.Context(), bucket, key, nil, http.Header{}, ObjectOptions{VersionID: id})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := io.ReadAll(gr)
|
||||
gr.Close()
|
||||
if err != nil || string(data) != "original" {
|
||||
t.Fatalf("pool %d: changed protected data: %q %v", i, data, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLockedSSECReplicaRejectsChangedSeal(t *testing.T) {
|
||||
stored := ObjectInfo{Bucket: "bucket", Name: "key", Size: 0, UserDefined: batchLockMeta("LEGALHOLD")}
|
||||
stored.UserDefined[crypto.MetaSealedKeySSEC] = "stored-seal"
|
||||
incoming := stored
|
||||
incoming.UserDefined = maps.Clone(stored.UserDefined)
|
||||
incoming.UserDefined[crypto.MetaSealedKeySSEC] = "changed-seal"
|
||||
open := func() (*GetObjectReader, error) {
|
||||
t.Fatal("changed seal must be rejected before reading")
|
||||
return nil, nil
|
||||
}
|
||||
if err := verifyLockedReplica(t.Context(), incoming, stored, open, open); err == nil {
|
||||
t.Fatal("changed SSE-C seal accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIReplicaCopyPreservesProtectedVersion(t *testing.T) {
|
||||
defer DetectTestLeak(t)()
|
||||
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
|
||||
t: t, makeBucketOptions: MakeBucketOptions{LockEnabled: true},
|
||||
objAPITest: func(obj ObjectLayer, backend, bucket string, router http.Handler, cred auth.Credentials, t *testing.T) {
|
||||
const key = "copy-protected-version"
|
||||
original := batchPutVersion(t, obj, bucket, key, batchLockMeta("COMPLIANCE"))
|
||||
data := []byte("different source version")
|
||||
source, err := obj.PutObject(t.Context(), bucket, key, mustGetPutObjReader(t, bytes.NewReader(data), int64(len(data)), "", ""), ObjectOptions{Versioned: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
headers := map[string]string{
|
||||
xhttp.AmzCopySource: url.QueryEscape("/"+bucket+"/"+key) + "?versionId=" + source.VersionID,
|
||||
xhttp.MinIOSourceReplicationRequest: "true", xhttp.AmzBucketReplicationStatus: "REPLICA",
|
||||
xhttp.AmzMetadataDirective: replaceDirective,
|
||||
}
|
||||
req, err := newTestSignedRequestV4(http.MethodPut, getCopyObjectURL("", bucket, key)+"?versionId="+original.VersionID, 0, nil, cred.AccessKey, cred.SecretKey, headers)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code < 400 || !strings.Contains(rec.Body.String(), "WORM") {
|
||||
t.Fatalf("%s: copy replaced protected version: %d %s", backend, rec.Code, rec.Body.String())
|
||||
}
|
||||
gr, err := obj.GetObjectNInfo(t.Context(), bucket, key, nil, http.Header{}, ObjectOptions{VersionID: original.VersionID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := io.ReadAll(gr)
|
||||
gr.Close()
|
||||
if err != nil || string(got) != "batch lock "+key {
|
||||
t.Fatalf("%s: protected body changed: %q %v", backend, got, err)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestAPIReplicaCannotWeakenCompliance(t *testing.T) {
|
||||
defer DetectTestLeak(t)()
|
||||
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
|
||||
t: t, makeBucketOptions: MakeBucketOptions{LockEnabled: true},
|
||||
objAPITest: func(obj ObjectLayer, backend, bucket string, router http.Handler, cred auth.Credentials, t *testing.T) {
|
||||
const key = "compliance-removal"
|
||||
oi := batchPutVersion(t, obj, bucket, key, batchLockMeta("COMPLIANCE"))
|
||||
until := oi.UserDefined[strings.ToLower(xhttp.AmzObjectLockRetainUntilDate)]
|
||||
for _, copyRequest := range []bool{false, true} {
|
||||
headers := map[string]string{xhttp.MinIOSourceReplicationRequest: "true", xhttp.AmzBucketReplicationStatus: "REPLICA", xhttp.MinIOSourceObjectRetentionTimestamp: UTCNow().Add(time.Hour).Format(time.RFC3339Nano)}
|
||||
body := []byte("batch lock " + key)
|
||||
if copyRequest {
|
||||
headers[xhttp.AmzCopySource] = url.QueryEscape("/"+bucket+"/"+key) + "?versionId=" + oi.VersionID
|
||||
headers[xhttp.AmzMetadataDirective] = replaceDirective
|
||||
body = nil
|
||||
}
|
||||
req, err := newTestSignedRequestV4(http.MethodPut, getPutObjectURL("", bucket, key)+"?versionId="+oi.VersionID, int64(len(body)), bytes.NewReader(body), cred.AccessKey, cred.SecretKey, headers)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("%s copy=%v: %d %s", backend, copyRequest, rec.Code, rec.Body.String())
|
||||
}
|
||||
stored, err := obj.GetObjectInfo(t.Context(), bucket, key, ObjectOptions{VersionID: oi.VersionID})
|
||||
if err != nil || stored.UserDefined[strings.ToLower(xhttp.AmzObjectLockMode)] != "COMPLIANCE" || stored.UserDefined[strings.ToLower(xhttp.AmzObjectLockRetainUntilDate)] != until {
|
||||
t.Fatalf("%s copy=%v: a replica cleared COMPLIANCE: %+v %v", backend, copyRequest, stored.UserDefined, err)
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
// Copyright (c) 2026 PGSTY
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/minio/minio/internal/amztime"
|
||||
"github.com/minio/minio/internal/auth"
|
||||
xhttp "github.com/minio/minio/internal/http"
|
||||
)
|
||||
|
||||
// replicaDeleteRequest sends DELETE ?versionId= as site replication does.
|
||||
func replicaDeleteRequest(t *testing.T, router http.Handler, cred auth.Credentials, bucket, key, versionID string, marker bool) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
target := getDeleteObjectURL("", bucket, key) + "?" + url.Values{xhttp.VersionID: {versionID}}.Encode()
|
||||
req, err := newTestSignedRequestV4(http.MethodDelete, target, 0, nil, cred.AccessKey, cred.SecretKey, map[string]string{
|
||||
xhttp.MinIOSourceReplicationRequest: "true",
|
||||
xhttp.AmzBucketReplicationStatus: "REPLICA",
|
||||
xhttp.MinIOSourceDeleteMarker: strconv.FormatBool(marker),
|
||||
xhttp.MinIOSourceMTime: UTCNow().Format(time.RFC3339Nano),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func replicaDeleteMarkerExists(t *testing.T, obj ObjectLayer, bucket, key, versionID string) bool {
|
||||
t.Helper()
|
||||
oi, err := obj.GetObjectInfo(t.Context(), bucket, key, ObjectOptions{VersionID: versionID})
|
||||
return oi.DeleteMarker && (err == nil || isErrMethodNotAllowed(err))
|
||||
}
|
||||
|
||||
// A replicated delete leaves a destination version under Object Lock as it
|
||||
// is, whether it purges that version or names it in a delete marker. The
|
||||
// replication credential is not a retention bypass on the destination.
|
||||
func TestReplicaDeleteObjectLock(t *testing.T) {
|
||||
defer DetectTestLeak(t)()
|
||||
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
|
||||
t: t, endpoints: []string{"DeleteObject"},
|
||||
objAPITest: func(obj ObjectLayer, instance, bucket string, router http.Handler, cred auth.Credentials, t *testing.T) {
|
||||
enableBucketObjectLock(t, bucket)
|
||||
for _, kind := range batchLockKinds {
|
||||
for _, marker := range []bool{false, true} {
|
||||
key := "locked/" + kind + "/marker=" + strconv.FormatBool(marker)
|
||||
before := batchPutVersion(t, obj, bucket, key, batchLockMeta(kind))
|
||||
rec := replicaDeleteRequest(t, router, cred, bucket, key, before.VersionID, marker)
|
||||
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "WORM") {
|
||||
t.Errorf("%s: %s: status %d, want 400 WORM: %s", instance, key, rec.Code, rec.Body.String())
|
||||
}
|
||||
after, err := obj.GetObjectInfo(t.Context(), bucket, key, ObjectOptions{VersionID: before.VersionID})
|
||||
if err != nil || after.DeleteMarker || after.ETag != before.ETag {
|
||||
t.Errorf("%s: %s: locked version was removed or replaced: %v", instance, key, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Controls: replication still purges unprotected versions and
|
||||
// writes new delete markers, also beside a locked version.
|
||||
expired := batchLockMeta("GOVERNANCE")
|
||||
expired[strings.ToLower(xhttp.AmzObjectLockRetainUntilDate)] = amztime.ISO8601Format(UTCNow().Add(-time.Hour))
|
||||
for name, meta := range map[string]map[string]string{"unlocked": nil, "expired": expired} {
|
||||
key := "purge/" + name
|
||||
v := batchPutVersion(t, obj, bucket, key, meta)
|
||||
if rec := replicaDeleteRequest(t, router, cred, bucket, key, v.VersionID, false); rec.Code != http.StatusNoContent {
|
||||
t.Errorf("%s: %s: status %d, want 204: %s", instance, key, rec.Code, rec.Body.String())
|
||||
}
|
||||
if batchVersionExists(t, obj, bucket, key, v.VersionID) {
|
||||
t.Errorf("%s: %s: version survived the replicated purge", instance, key)
|
||||
}
|
||||
}
|
||||
|
||||
const key = "marker/new"
|
||||
locked := batchPutVersion(t, obj, bucket, key, batchLockMeta("COMPLIANCE"))
|
||||
marker := mustGetUUID()
|
||||
if rec := replicaDeleteRequest(t, router, cred, bucket, key, marker, true); rec.Code != http.StatusNoContent {
|
||||
t.Errorf("%s: new marker: status %d, want 204: %s", instance, rec.Code, rec.Body.String())
|
||||
}
|
||||
if !replicaDeleteMarkerExists(t, obj, bucket, key, marker) {
|
||||
t.Errorf("%s: the replicated delete marker was not written", instance)
|
||||
}
|
||||
if !batchVersionExists(t, obj, bucket, key, locked.VersionID) {
|
||||
t.Errorf("%s: a new marker removed a locked version", instance)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// With more than one pool, the protected version may be in a pool other than
|
||||
// the one that holds the latest version and takes the delete marker.
|
||||
func TestPoolsReplicaDeleteObjectLock(t *testing.T) {
|
||||
z, _ := consistencyPools(t)
|
||||
bucket, router, err := initAPIHandlerTest(t.Context(), z, nil, MakeBucketOptions{VersioningEnabled: true, LockEnabled: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for pool := range z.serverPools {
|
||||
for _, marker := range []bool{false, true} {
|
||||
key := "pool-" + strconv.Itoa(pool) + "/marker=" + strconv.FormatBool(marker)
|
||||
before := putConsistencyObject(t, z, bucket, key, pool, "locked",
|
||||
ObjectOptions{Versioned: true, MTime: UTCNow().Add(-time.Hour), UserDefined: batchLockMeta("COMPLIANCE")})
|
||||
putConsistencyObject(t, z, bucket, key, 1-pool, "newer", ObjectOptions{Versioned: true, MTime: UTCNow()})
|
||||
rec := replicaDeleteRequest(t, router, globalActiveCred, bucket, key, before.VersionID, marker)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("%s: status %d, want 400: %s", key, rec.Code, rec.Body.String())
|
||||
}
|
||||
// The logical lookup also finds a marker written with this ID in
|
||||
// the other pool, which would shadow the protected version.
|
||||
after, gerr := z.GetObjectInfo(t.Context(), bucket, key, ObjectOptions{VersionID: before.VersionID})
|
||||
if gerr != nil || after.DeleteMarker || after.ETag != before.ETag {
|
||||
t.Errorf("%s: locked version was removed, replaced or shadowed: %v", key, gerr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// Copyright (c) 2026 Feng Ruohang
|
||||
//
|
||||
// This file is part of Silo Object Storage stack
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/minio/minio/internal/auth"
|
||||
xhttp "github.com/minio/minio/internal/http"
|
||||
)
|
||||
|
||||
func TestReplicationControlsRequireSignedHeaders(t *testing.T) {
|
||||
for _, header := range replicationRequestHeaders {
|
||||
t.Run(header, func(t *testing.T) {
|
||||
req, err := newTestSignedRequestV4(http.MethodGet, "http://localhost/bucket/key", 0, nil, "access-key", "secret-key", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Membership matters even for a present-empty internal header.
|
||||
req.Header.Set(header, "")
|
||||
if replicationHeadersSigned(req) {
|
||||
t.Fatal("unsigned control granted replication trust")
|
||||
}
|
||||
if err := signRequestV4(req, "access-key", "secret-key"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !replicationHeadersSigned(req) {
|
||||
t.Fatal("signed control rejected")
|
||||
}
|
||||
})
|
||||
}
|
||||
for _, authorization := range []string{"", "AWS access-key:signature", "Credential=access-key"} {
|
||||
req := httptest.NewRequest(http.MethodGet, "/bucket/key", nil)
|
||||
req.Header.Set(xhttp.Authorization, authorization)
|
||||
req.Header.Set(xhttp.MinIOSourceReplicationRequest, "true")
|
||||
if replicationHeadersSigned(req) {
|
||||
t.Fatalf("non-SigV4 authorization granted trust: %q", authorization)
|
||||
}
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "http://localhost/bucket/key", nil)
|
||||
req.Header.Set(xhttp.MinIOSourceReplicationRequest, "true")
|
||||
presignBoundaryRequest(t, req, UTCNow(), []string{"host", "x-minio-source-replication-request"}, auth.Credentials{AccessKey: "access-key", SecretKey: "secret-key"})
|
||||
getRequestAuthType(req)
|
||||
if replicationHeadersSigned(req) {
|
||||
t.Fatal("presigned request granted peer trust")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIUnsignedReplicationControlsAreIgnored(t *testing.T) {
|
||||
defer DetectTestLeak(t)()
|
||||
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
|
||||
t: t, endpoints: []string{"PutObject"},
|
||||
objAPITest: func(obj ObjectLayer, backend, bucket string, router http.Handler, cred auth.Credentials, t *testing.T) {
|
||||
for _, signedMarker := range []bool{false, true} {
|
||||
headers := map[string]string{}
|
||||
if signedMarker {
|
||||
headers[xhttp.MinIOSourceReplicationRequest] = "true"
|
||||
}
|
||||
body := []byte("replication controls are authenticated")
|
||||
req, err := newTestSignedRequestV4(http.MethodPut, getPutObjectURL("", bucket, "key"), int64(len(body)), bytes.NewReader(body), cred.AccessKey, cred.SecretKey, headers)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set(xhttp.MinIOSourceReplicationRequest, "true")
|
||||
req.Header.Set(xhttp.MinIOSourceMTime, "2100-01-01T00:00:00Z")
|
||||
req.Header.Set(xhttp.MinIOSourceETag, "forged-source-etag")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
oi, err := obj.GetObjectInfo(t.Context(), bucket, "key", ObjectOptions{})
|
||||
if rec.Code != http.StatusOK || err != nil || oi.ModTime.Year() == 2100 || oi.ETag == "forged-source-etag" {
|
||||
t.Fatalf("%s signedMarker=%v: unsigned replication state was accepted: HTTP=%d info=%+v err=%v", backend, signedMarker, rec.Code, oi, err)
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1850,6 +1850,12 @@ func testReplicaLockReconcileNullVersion(obj ObjectLayer, instanceType, bucketNa
|
||||
_ http.Handler, _ auth.Credentials, t *testing.T,
|
||||
) {
|
||||
ctx := t.Context()
|
||||
// A protected retransmit must preserve both the source ciphertext and
|
||||
// its seal. Generate it once for the stored null version and its retry.
|
||||
var ciphertext bytes.Buffer
|
||||
if _, err := sio.Encrypt(&ciphertext, bytes.NewReader([]byte("data")), sio.Config{Key: bytes.Repeat([]byte{1}, 32)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// completeNullMPU runs an SSE-C replica multipart upload addressing the null
|
||||
// version (VersionSuspended) carrying uploadLock, through the reconcile.
|
||||
@@ -1861,13 +1867,6 @@ func testReplicaLockReconcileNullVersion(obj ObjectLayer, instanceType, bucketNa
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// PutObjectPart now validates the DARE stream length (#119). This
|
||||
// object-layer fixture needs an encrypted body before it can exercise
|
||||
// the completion-time null-version metadata reconciliation.
|
||||
var ciphertext bytes.Buffer
|
||||
if _, err := sio.Encrypt(&ciphertext, bytes.NewReader([]byte("data")), sio.Config{Key: bytes.Repeat([]byte{1}, 32)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
part, err := obj.PutObjectPart(ctx, bucketName, object, res.UploadID, 1,
|
||||
mustGetPutObjReader(t, bytes.NewReader(ciphertext.Bytes()), int64(ciphertext.Len()), "", ""), ObjectOptions{})
|
||||
if err != nil {
|
||||
@@ -1884,8 +1883,9 @@ func testReplicaLockReconcileNullVersion(obj ObjectLayer, instanceType, bucketNa
|
||||
object := "reconcile/null-vs-uuid"
|
||||
// The null version holds a newer legal hold ON@10:00.
|
||||
if _, err := obj.PutObject(ctx, bucketName, object,
|
||||
mustGetPutObjReader(t, bytes.NewReader([]byte("data")), 4, "", ""),
|
||||
mustGetPutObjReader(t, bytes.NewReader(ciphertext.Bytes()), int64(ciphertext.Len()), "", ""),
|
||||
ObjectOptions{VersionSuspended: true, MTime: time.Date(2026, 9, 3, 10, 0, 0, 0, time.UTC), UserDefined: map[string]string{
|
||||
crypto.MetaSealedKeySSEC: "dummy-sealed-key",
|
||||
strings.ToLower(xhttp.AmzObjectLockLegalHold): "ON",
|
||||
ReservedMetadataPrefixLower + ObjectLockLegalHoldTimestamp: objectLockTestStamp1000,
|
||||
}}); err != nil {
|
||||
|
||||
@@ -13,6 +13,8 @@ package cmd
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
objectreplication "github.com/minio/minio/internal/bucket/replication"
|
||||
xhttp "github.com/minio/minio/internal/http"
|
||||
@@ -62,7 +64,7 @@ func isReplicaTrusted(ctx context.Context) bool {
|
||||
// grant must not turn client-controlled internal headers into trusted state.
|
||||
func replicationPermissionAllowed(ctx context.Context, r *http.Request, bucket, object string, action policy.Action) bool {
|
||||
reqInfo := logger.GetReqInfo(ctx)
|
||||
if reqInfo == nil || reqInfo.Cred.AccessKey == "" {
|
||||
if reqInfo == nil || reqInfo.Cred.AccessKey == "" || !replicationHeadersSigned(r) {
|
||||
return false
|
||||
}
|
||||
reqInfo.BucketName = bucket
|
||||
@@ -70,6 +72,25 @@ func replicationPermissionAllowed(ctx context.Context, r *http.Request, bucket,
|
||||
return authorizeRequest(ctx, r, action) == ErrNone
|
||||
}
|
||||
|
||||
// replicationHeadersSigned binds every internal control to the authenticated
|
||||
// header signature. Query grants and SigV2 requests cannot confer peer trust.
|
||||
// Call only after the request's signature has been verified.
|
||||
func replicationHeadersSigned(r *http.Request) bool {
|
||||
if !isRequestSignatureV4(r) || isRequestPresignedSignatureV4(r) {
|
||||
return false
|
||||
}
|
||||
sig, errCode := parseSignV4(r.Header.Get(xhttp.Authorization), globalSite.Region(), serviceS3)
|
||||
if errCode != ErrNone {
|
||||
return false
|
||||
}
|
||||
for _, name := range replicationRequestHeaders {
|
||||
if _, present := r.Header[http.CanonicalHeaderKey(name)]; present && !slices.Contains(sig.SignedHeaders, strings.ToLower(name)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// evaluateReplicationTrust decides whether a request may carry replication
|
||||
// semantics for the given action. A request that declares itself a replica
|
||||
// without holding the replication permission is rejected. trusted reports that
|
||||
|
||||
@@ -43,6 +43,11 @@ const (
|
||||
// restoreTakeoverWait bounds how long a POST waits for the restore lock
|
||||
// of its version. A running restore holds it for its whole run.
|
||||
restoreTakeoverWait = 2 * time.Second
|
||||
// restoreResetAttemptTimeout bounds the synchronous cleanup attempt made
|
||||
// after a restore fails. A caller whose context has ended must not wait for
|
||||
// another object operation to release the restore lock; the recovery loop
|
||||
// below retries the cleanup after this attempt gives up.
|
||||
restoreResetAttemptTimeout = 100 * time.Millisecond
|
||||
// restoreCleanupTimeout bounds each status reset and convergence pass.
|
||||
restoreCleanupTimeout = time.Minute
|
||||
)
|
||||
@@ -639,6 +644,10 @@ func (z *erasureServerPools) recoverRestoreStatus(bucket, object string, opts Ob
|
||||
func (z *erasureServerPools) resetRestoreStatus(bucket, object string, opts ObjectOptions) error {
|
||||
ctx, cancel := context.WithTimeout(GlobalContext, restoreCleanupTimeout)
|
||||
defer cancel()
|
||||
return z.resetRestoreStatusWithContext(ctx, bucket, object, opts)
|
||||
}
|
||||
|
||||
func (z *erasureServerPools) resetRestoreStatusWithContext(ctx context.Context, bucket, object string, opts ObjectOptions) error {
|
||||
lk := z.restoreLock(bucket, object)
|
||||
lkctx, err := lk.GetLock(ctx, globalOperationTimeout)
|
||||
if err != nil {
|
||||
|
||||
@@ -455,7 +455,13 @@ func TestRestoreRecoversStrandedMarker(t *testing.T) {
|
||||
t.Fatal(lerr)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond)
|
||||
start := time.Now()
|
||||
err = r.z.RestoreTransitionedObject(ctx, r.bucket, r.object, nativeRestoreOptions(r, oi.VersionID))
|
||||
// The canceled caller must not wait for the minute-long
|
||||
// background cleanup deadline before releasing its lock.
|
||||
if elapsed := time.Since(start); elapsed > 5*time.Second {
|
||||
t.Errorf("canceled restore blocked on marker cleanup for %s", elapsed)
|
||||
}
|
||||
cancel()
|
||||
lk.Unlock(lkctx)
|
||||
} else {
|
||||
|
||||
@@ -263,7 +263,13 @@ func TestNativeRestoreWithoutOuterLock(t *testing.T) {
|
||||
before := nativeRestoreSnapshot(t, r)
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 200*time.Millisecond)
|
||||
defer cancel()
|
||||
start := time.Now()
|
||||
err = er.RestoreTransitionedObject(ctx, r.bucket, r.object, nativeRestoreOptions(r, oi.VersionID))
|
||||
// Failure cleanup must return while this caller still holds the
|
||||
// object lock, leaving marker recovery to the background retry.
|
||||
if elapsed := time.Since(start); elapsed > 5*time.Second {
|
||||
t.Errorf("restore blocked on failure cleanup for %s", elapsed)
|
||||
}
|
||||
if err == nil {
|
||||
t.Error("restore wrote through another caller's lock")
|
||||
}
|
||||
|
||||
+36
-8
@@ -793,22 +793,47 @@ func (er erasureObjects) RestoreTransitionedObject(ctx context.Context, bucket,
|
||||
return lkctx, func() { lk.Unlock(lkctx) }, nil
|
||||
}
|
||||
clearMarker := func() {
|
||||
ctx, cancel := context.WithTimeout(GlobalContext, restoreCleanupTimeout)
|
||||
defer cancel()
|
||||
tryClear := func(ctx context.Context) error {
|
||||
lkctx, unlock, err := lock(ctx)
|
||||
if err != nil {
|
||||
storageLogIf(ctx, fmt.Errorf("Unable to clear the restore status of %s/%s(%s): %w", bucket, object, opts.VersionID, err))
|
||||
return
|
||||
return err
|
||||
}
|
||||
defer unlock()
|
||||
versionID := opts.VersionID
|
||||
if a := opts.Transition.attempt; a != nil {
|
||||
versionID = a.VersionID
|
||||
}
|
||||
if err := er.clearRestoreStatus(lkctx.Context(), bucket, object, versionID, opts.Transition.attempt); err != nil &&
|
||||
!isErrObjectNotFound(err) && !isErrVersionNotFound(err) && !isErrMethodNotAllowed(err) {
|
||||
storageLogIf(ctx, fmt.Errorf("Unable to clear the restore status of %s/%s(%s): %w", bucket, object, versionID, err))
|
||||
err = er.clearRestoreStatus(lkctx.Context(), bucket, object, versionID, opts.Transition.attempt)
|
||||
if isErrObjectNotFound(err) || isErrVersionNotFound(err) || isErrMethodNotAllowed(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(GlobalContext, restoreResetAttemptTimeout)
|
||||
err := tryClear(ctx)
|
||||
cancel()
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
|
||||
go func() {
|
||||
var lastErr error
|
||||
for _, delay := range restoreRecoveryDelays {
|
||||
select {
|
||||
case <-GlobalContext.Done():
|
||||
return
|
||||
case <-time.After(delay):
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(GlobalContext, restoreCleanupTimeout)
|
||||
lastErr = tryClear(ctx)
|
||||
cancel()
|
||||
if lastErr == nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
storageLogIf(GlobalContext, fmt.Errorf("Unable to clear the restore status of %s/%s(%s): %w", bucket, object, opts.VersionID, lastErr))
|
||||
}()
|
||||
}
|
||||
return er.restoreVersion(ctx, bucket, object, opts, restoreHooks{lock: lock, clear: clearMarker})
|
||||
}
|
||||
@@ -835,7 +860,10 @@ func (z *erasureServerPools) RestoreTransitionedObject(ctx context.Context, buck
|
||||
opts.SkipDecommissioned = true
|
||||
opts.NoLock = true
|
||||
clearMarker := func() {
|
||||
if err := z.resetRestoreStatus(bucket, object, opts); err != nil {
|
||||
ctx, cancel := context.WithTimeout(GlobalContext, restoreResetAttemptTimeout)
|
||||
err := z.resetRestoreStatusWithContext(ctx, bucket, object, opts)
|
||||
cancel()
|
||||
if err != nil {
|
||||
z.recoverRestoreStatus(bucket, object, opts)
|
||||
}
|
||||
}
|
||||
|
||||
+14
-20
@@ -144,13 +144,7 @@ func (api objectAPIHandlers) getObjectInArchiveFileHandler(ctx context.Context,
|
||||
|
||||
zipInfo := zipObjInfo.ArchiveInfo(r.Header)
|
||||
if len(zipInfo) == 0 {
|
||||
opts.EncryptFn, err = zipObjInfo.metadataEncryptFn(r.Header)
|
||||
if err != nil {
|
||||
writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL)
|
||||
return
|
||||
}
|
||||
|
||||
zipInfo, err = updateObjectMetadataWithZipInfo(ctx, objectAPI, bucket, zipPath, opts)
|
||||
zipInfo, _, err = readObjectZIPInfo(ctx, objectAPI, bucket, zipPath, opts)
|
||||
}
|
||||
if err != nil {
|
||||
writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL)
|
||||
@@ -245,8 +239,7 @@ func listObjectsV2InArchive(ctx context.Context, objectAPI ObjectLayer, bucket,
|
||||
|
||||
zipInfo := zipObjInfo.ArchiveInfo(h)
|
||||
if len(zipInfo) == 0 {
|
||||
// Always update the latest version
|
||||
zipInfo, err = updateObjectMetadataWithZipInfo(ctx, objectAPI, bucket, zipPath, ObjectOptions{})
|
||||
zipInfo, _, err = readObjectZIPInfo(ctx, objectAPI, bucket, zipPath, ObjectOptions{})
|
||||
}
|
||||
if err != nil {
|
||||
return ListObjectsV2Info{}, err
|
||||
@@ -437,12 +430,7 @@ func (api objectAPIHandlers) headObjectInArchiveFileHandler(ctx context.Context,
|
||||
|
||||
zipInfo := zipObjInfo.ArchiveInfo(r.Header)
|
||||
if len(zipInfo) == 0 {
|
||||
opts.EncryptFn, err = zipObjInfo.metadataEncryptFn(r.Header)
|
||||
if err != nil {
|
||||
writeErrorResponseHeadersOnly(w, toAPIError(ctx, err))
|
||||
return
|
||||
}
|
||||
zipInfo, err = updateObjectMetadataWithZipInfo(ctx, objectAPI, bucket, zipPath, opts)
|
||||
zipInfo, _, err = readObjectZIPInfo(ctx, objectAPI, bucket, zipPath, opts)
|
||||
}
|
||||
if err != nil {
|
||||
writeErrorResponseHeadersOnly(w, toAPIError(ctx, err))
|
||||
@@ -482,15 +470,22 @@ func (api objectAPIHandlers) headObjectInArchiveFileHandler(ctx context.Context,
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// Update the passed zip object metadata with the zip contents info, file name, modtime, size, etc.
|
||||
// The returned zip index will de decrypted.
|
||||
func updateObjectMetadataWithZipInfo(ctx context.Context, objectAPI ObjectLayer, bucket, object string, opts ObjectOptions) ([]byte, error) {
|
||||
// readObjectZIPInfo builds an index without modifying the object. A GET, HEAD
|
||||
// or archive listing must remain read-only even when no index was stored.
|
||||
func readObjectZIPInfo(ctx context.Context, objectAPI ObjectLayer, bucket, object string, opts ObjectOptions) ([]byte, ObjectInfo, error) {
|
||||
files, srcInfo, err := getFilesListFromZIPObject(ctx, objectAPI, bucket, object, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, srcInfo, err
|
||||
}
|
||||
files.OptimizeSize()
|
||||
zipInfo, err := files.Serialize()
|
||||
return zipInfo, srcInfo, err
|
||||
}
|
||||
|
||||
// updateObjectMetadataWithZipInfo persists an index only for upload operations
|
||||
// that explicitly request extraction. The returned index is decrypted.
|
||||
func updateObjectMetadataWithZipInfo(ctx context.Context, objectAPI ObjectLayer, bucket, object string, opts ObjectOptions) ([]byte, error) {
|
||||
zipInfo, srcInfo, err := readObjectZIPInfo(ctx, objectAPI, bucket, object, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -500,7 +495,6 @@ func updateObjectMetadataWithZipInfo(ctx context.Context, objectAPI ObjectLayer,
|
||||
at = archiveTypeEnc
|
||||
zipInfoStr = string(opts.EncryptFn(archiveTypeEnc, zipInfo))
|
||||
}
|
||||
srcInfo.UserDefined[archiveTypeMetadataKey] = at
|
||||
popts := ObjectOptions{
|
||||
MTime: srcInfo.ModTime,
|
||||
VersionID: srcInfo.VersionID,
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
// Copyright (c) 2026 Feng Ruohang
|
||||
//
|
||||
// This file is part of Silo Object Storage stack
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/minio/minio/internal/auth"
|
||||
)
|
||||
|
||||
func TestAPIArchiveReadsDoNotPersistMetadata(t *testing.T) {
|
||||
defer DetectTestLeak(t)()
|
||||
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
|
||||
t: t, makeBucketOptions: MakeBucketOptions{LockEnabled: true},
|
||||
objAPITest: func(obj ObjectLayer, backend, bucket string, router http.Handler, cred auth.Credentials, t *testing.T) {
|
||||
var archive bytes.Buffer
|
||||
zw := zip.NewWriter(&archive)
|
||||
file, err := zw.Create("entry.txt")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = file.Write([]byte("archive content")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = zw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reader := newObjectAttributesAuthzUser(t, backend, bucket, `"s3:GetObject","s3:ListBucket"`)
|
||||
policy := fmt.Appendf(nil, `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","Action":["s3:GetObject","s3:ListBucket"],"Resource":["arn:aws:s3:::%s","arn:aws:s3:::%s/*"]}]}`, bucket, bucket)
|
||||
if _, err = globalBucketMetadataSys.Update(t.Context(), bucket, bucketPolicyConfig, policy); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, anonymous := range []bool{false, true} {
|
||||
for _, method := range []string{http.MethodGet, http.MethodHead, "LIST"} {
|
||||
key := fmt.Sprintf("archive-%v-%s.zip", anonymous, method)
|
||||
before, err := obj.PutObject(t.Context(), bucket, key, mustGetPutObjReader(t, bytes.NewReader(archive.Bytes()), int64(archive.Len()), "", ""), ObjectOptions{Versioned: true, UserDefined: batchLockMeta("COMPLIANCE")})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before, err = obj.GetObjectInfo(t.Context(), bucket, key, ObjectOptions{VersionID: before.VersionID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
url := getGetObjectURL("", bucket, key+"/entry.txt")
|
||||
verb := method
|
||||
if method == "LIST" {
|
||||
verb = http.MethodGet
|
||||
url = "/" + bucket + "?list-type=2&prefix=" + key + "/"
|
||||
}
|
||||
req, err := newTestSignedRequestV4(verb, url, 0, nil, reader.AccessKey, reader.SecretKey, map[string]string{xMinIOExtract: "true"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if anonymous {
|
||||
req.Header.Del("Authorization")
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("%s anonymous=%v %s: %d %s", backend, anonymous, method, rec.Code, rec.Body.String())
|
||||
}
|
||||
if method == http.MethodGet && rec.Body.String() != "archive content" {
|
||||
t.Fatalf("wrong archive entry: %s", rec.Body.String())
|
||||
}
|
||||
if method == "LIST" && !bytes.Contains(rec.Body.Bytes(), []byte("entry.txt")) {
|
||||
t.Fatal("archive listing omitted entry")
|
||||
}
|
||||
after, err := obj.GetObjectInfo(t.Context(), bucket, key, ObjectOptions{VersionID: before.VersionID})
|
||||
if err != nil || !maps.Equal(before.UserDefined, after.UserDefined) || !after.ModTime.Equal(before.ModTime) || after.ETag != before.ETag {
|
||||
t.Fatalf("%s anonymous=%v %s changed stored metadata: before=%v after=%v err=%v", backend, anonymous, method, before.UserDefined, after.UserDefined, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Explicit extraction during an authorized upload still builds and
|
||||
// persists the index for subsequent reads.
|
||||
req, err := newTestSignedRequestV4(http.MethodPut, getPutObjectURL("", bucket, "indexed.zip"), int64(archive.Len()), bytes.NewReader(archive.Bytes()), cred.AccessKey, cred.SecretKey, map[string]string{xMinIOExtract: "true"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
oi, err := obj.GetObjectInfo(t.Context(), bucket, "indexed.zip", ObjectOptions{})
|
||||
if rec.Code != http.StatusOK || err != nil || len(oi.ArchiveInfo(nil)) == 0 {
|
||||
t.Fatalf("explicit extraction lost index: %d %s %v", rec.Code, rec.Body.String(), err)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
+15
-76
@@ -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,40 +2372,10 @@ 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,
|
||||
},
|
||||
},
|
||||
SvcAccChange: &madmin.SRSvcAccChange{Create: &madmin.SRSvcAccCreate{AccessKey: user}},
|
||||
UpdatedAt: acc.UpdatedAt,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -5485,49 +5462,11 @@ 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,
|
||||
},
|
||||
},
|
||||
SvcAccChange: &madmin.SRSvcAccChange{Create: &madmin.SRSvcAccCreate{AccessKey: creds.AccessKey}},
|
||||
UpdatedAt: lastUpdate,
|
||||
}); err != nil {
|
||||
replLogOnceIf(ctx,
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
// Copyright (c) 2026 Feng Ruohang
|
||||
//
|
||||
// This file is part of Silo Object Storage stack
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/minio/minio/internal/auth"
|
||||
xhttp "github.com/minio/minio/internal/http"
|
||||
)
|
||||
|
||||
func TestAPIStreamingAuthenticationBoundaries(t *testing.T) {
|
||||
defer DetectTestLeak(t)()
|
||||
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
|
||||
t: t, endpoints: []string{"PutObject"},
|
||||
objAPITest: func(obj ObjectLayer, backend, bucket string, router http.Handler, cred auth.Credentials, t *testing.T) {
|
||||
data := []byte("authenticated streaming payload")
|
||||
for _, mode := range []string{"valid", "missing-algorithm", "wrong-algorithm", "wrong-signature", "invalid-credential"} {
|
||||
req, err := newStreamingUnsignedTrailerRequest(http.MethodPut, getPutObjectURL("", bucket, mode), data, UTCNow())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := signRequestV4(req, cred.AccessKey, cred.SecretKey); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
authorization := req.Header.Get(xhttp.Authorization)
|
||||
switch mode {
|
||||
case "missing-algorithm":
|
||||
authorization = strings.TrimPrefix(authorization, signV4Algorithm+" ")
|
||||
case "wrong-algorithm":
|
||||
authorization = strings.Replace(authorization, signV4Algorithm, "INVALID", 1)
|
||||
case "wrong-signature":
|
||||
prefix, _, found := strings.Cut(authorization, "Signature=")
|
||||
if !found {
|
||||
t.Fatal("signed request has no signature")
|
||||
}
|
||||
authorization = prefix + "Signature=" + strings.Repeat("0", 64)
|
||||
case "invalid-credential":
|
||||
authorization = strings.Replace(authorization, cred.AccessKey, strings.Repeat("A", len(cred.AccessKey)), 1)
|
||||
}
|
||||
req.Header.Set(xhttp.Authorization, authorization)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
_, stored := obj.GetObjectInfo(t.Context(), bucket, mode, ObjectOptions{})
|
||||
if mode == "valid" {
|
||||
if rec.Code != http.StatusOK || stored != nil {
|
||||
t.Fatalf("%s %s: %d %s, object: %v", backend, mode, rec.Code, rec.Body.String(), stored)
|
||||
}
|
||||
} else if rec.Code < 400 || stored == nil {
|
||||
t.Errorf("%s %s: malformed authentication wrote an object: %d %s", backend, mode, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
for _, trailers := range []bool{false, true} {
|
||||
req, err := newTestStreamingSignedRequest(http.MethodPut, getPutObjectURL("", bucket, "tagged"), int64(len(data)), 64*1024, bytes.NewReader(data), cred.AccessKey, cred.SecretKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if trailers {
|
||||
req.Header.Set(xhttp.AmzContentSha256, streamingContentSHA256Trailer)
|
||||
req.Header.Set(awsTrailerHeader, "x-amz-checksum-crc32")
|
||||
if err := signRequestV4(req, cred.AccessKey, cred.SecretKey); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
getRequestAuthType(req)
|
||||
if _, _, _, _, code := calculateSeedSignature(req, trailers); code != ErrNone {
|
||||
t.Fatalf("%s trailers=%v: valid seed rejected: %v", backend, trailers, code)
|
||||
}
|
||||
req.Header.Set(xhttp.AmzObjectTagging, "unsigned=injected")
|
||||
if _, code := newSignV4ChunkedReader(req, trailers); code != ErrUnsignedHeaders {
|
||||
t.Errorf("%s trailers=%v: unsigned tags accepted: %v", backend, trailers, code)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if _, err := obj.GetObjectInfo(t.Context(), bucket, "tagged", ObjectOptions{}); rec.Code < 400 || err == nil {
|
||||
t.Errorf("%s trailers=%v: injected metadata stored: %d", backend, trailers, rec.Code)
|
||||
}
|
||||
}
|
||||
// A public bucket can still use the unsigned trailer decoder without
|
||||
// claiming credentials; authorization remains the handler's job.
|
||||
req, err := newStreamingUnsignedTrailerRequest(http.MethodPut, "/public/key", data, UTCNow())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rd, code := newUnsignedV4ChunkedReader(req, true)
|
||||
if code != ErrNone {
|
||||
t.Fatal(code)
|
||||
}
|
||||
got, err := io.ReadAll(rd)
|
||||
rd.Close()
|
||||
if err != nil || !bytes.Equal(got, data) {
|
||||
t.Fatalf("anonymous decoder: %q %v", got, err)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestStreamingTrailerConditionValues(t *testing.T) {
|
||||
tests := []struct {
|
||||
name, payload, authorization string
|
||||
wantVersion, wantAuthType string
|
||||
}{
|
||||
{
|
||||
name: "signed trailer",
|
||||
payload: streamingContentSHA256Trailer,
|
||||
authorization: "AWS4-HMAC-SHA256 Credential=test",
|
||||
wantVersion: signV4Algorithm,
|
||||
wantAuthType: "REST-HEADER",
|
||||
},
|
||||
{
|
||||
name: "authenticated unsigned trailer",
|
||||
payload: unsignedPayloadTrailer,
|
||||
authorization: "AWS4-HMAC-SHA256 Credential=test",
|
||||
wantVersion: signV4Algorithm,
|
||||
wantAuthType: "REST-HEADER",
|
||||
},
|
||||
{
|
||||
name: "anonymous unsigned trailer",
|
||||
payload: unsignedPayloadTrailer,
|
||||
wantVersion: "",
|
||||
wantAuthType: "",
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodPut, "http://minio.local/bucket/object", nil)
|
||||
r.Header.Set(xhttp.AmzContentSha256, tc.payload)
|
||||
if tc.authorization != "" {
|
||||
r.Header.Set(xhttp.Authorization, tc.authorization)
|
||||
}
|
||||
values := getConditionValues(r, "", auth.Credentials{})
|
||||
if got := strings.Join(values["signatureversion"], ""); got != tc.wantVersion {
|
||||
t.Fatalf("signatureversion = %q, want %q", got, tc.wantVersion)
|
||||
}
|
||||
if got := strings.Join(values["authType"], ""); got != tc.wantAuthType {
|
||||
t.Fatalf("authType = %q, want %q", got, tc.wantAuthType)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamingUnsignedTrailerDateGate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
date string
|
||||
authorized bool
|
||||
wantCall bool
|
||||
}{
|
||||
{name: "anonymous without date", authorized: false, wantCall: true},
|
||||
{name: "authenticated current", date: UTCNow().Format(iso8601Format), authorized: true, wantCall: true},
|
||||
{name: "authenticated missing", authorized: true, wantCall: false},
|
||||
{name: "authenticated malformed", date: "not-a-date", authorized: true, wantCall: false},
|
||||
{name: "authenticated old", date: UTCNow().Add(-globalMaxSkewTime - time.Second).Format(iso8601Format), authorized: true, wantCall: false},
|
||||
{name: "authenticated future", date: UTCNow().Add(globalMaxSkewTime + time.Second).Format(iso8601Format), authorized: true, wantCall: false},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodPut, "http://minio.local/bucket/object", nil)
|
||||
r.Header.Set(xhttp.AmzContentSha256, unsignedPayloadTrailer)
|
||||
if tc.authorized {
|
||||
r.Header.Set(xhttp.Authorization, "AWS4-HMAC-SHA256 Credential=test")
|
||||
}
|
||||
if tc.date != "" {
|
||||
r.Header.Set(xhttp.AmzDate, tc.date)
|
||||
}
|
||||
|
||||
called := false
|
||||
h := setAuthMiddleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
called = true
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, r)
|
||||
if called != tc.wantCall {
|
||||
t.Fatalf("handler called = %v, want %v; status=%d body=%q", called, tc.wantCall, rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -127,6 +127,9 @@ func calculateSeedSignature(r *http.Request, trailers bool) (cred auth.Credentia
|
||||
if errCode != ErrNone {
|
||||
return cred, "", "", time.Time{}, errCode
|
||||
}
|
||||
if errCode := checkUnsignedHeaders(extractedSignedHeaders, r); errCode != ErrNone {
|
||||
return cred, "", "", time.Time{}, errCode
|
||||
}
|
||||
|
||||
cred, _, errCode = checkKeyValid(r, signV4Values.Credential.accessKey)
|
||||
if errCode != ErrNone {
|
||||
|
||||
@@ -24,6 +24,8 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
xhttp "github.com/minio/minio/internal/http"
|
||||
)
|
||||
|
||||
// newUnsignedV4ChunkedReader returns a new s3UnsignedChunkedReader that translates
|
||||
@@ -42,8 +44,10 @@ func newUnsignedV4ChunkedReader(req *http.Request, trailer bool) (io.ReadCloser,
|
||||
if isRequestPresignedSignatureV4(req) {
|
||||
return nil, ErrSignatureVersionNotSupported
|
||||
}
|
||||
if isRequestSignatureV4(req) {
|
||||
if errCode := reqSignatureV4Verify(req, globalSite.Region(), serviceS3); errCode != ErrNone {
|
||||
// Any claimed header credential must pass the complete SigV4 parser. The
|
||||
// streaming auth classification does not require the algorithm prefix.
|
||||
if req.Header.Get(xhttp.Authorization) != "" {
|
||||
if errCode := doesSignatureMatch(unsignedPayloadTrailer, req, globalSite.Region(), serviceS3); errCode != ErrNone {
|
||||
return nil, errCode
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
// Copyright (c) 2026 Feng Ruohang
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/minio/madmin-go/v3"
|
||||
"github.com/minio/minio/internal/auth"
|
||||
"github.com/minio/minio/internal/bucket/replication"
|
||||
xhttp "github.com/minio/minio/internal/http"
|
||||
)
|
||||
|
||||
func TestAPITaggingProxyAuthorization(t *testing.T) {
|
||||
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{t: t, objAPITest: func(obj ObjectLayer, backend, bucket string, router http.Handler, root auth.Credentials, t *testing.T) {
|
||||
conditional := newDeleteAuthzPolicyUser(t, backend, bucket, fmt.Sprintf(`[
|
||||
{"Effect":"Allow","Action":["s3:GetObjectTagging","s3:PutObjectTagging","s3:DeleteObjectTagging"],"Resource":["arn:aws:s3:::%s/*"]},
|
||||
{"Effect":"Deny","Action":["s3:GetObjectTagging","s3:PutObjectTagging","s3:DeleteObjectTagging"],"Resource":["arn:aws:s3:::%s/*"],"Condition":{"StringEquals":{"s3:ExistingObjectTag/secret":"true"}}}
|
||||
]`, bucket, bucket))
|
||||
tagAllow := newDeleteAuthzPolicyUser(t, backend, bucket, fmt.Sprintf(`[
|
||||
{"Effect":"Allow","Action":["s3:GetObjectTagging","s3:PutObjectTagging","s3:DeleteObjectTagging"],"Resource":["arn:aws:s3:::%s/*"],"Condition":{"StringEquals":{"s3:ExistingObjectTag/secret":"false"}}}
|
||||
]`, bucket))
|
||||
denied := newDeleteAuthzPolicyUser(t, backend, bucket, fmt.Sprintf(`[
|
||||
{"Effect":"Deny","Action":["s3:*"],"Resource":["arn:aws:s3:::%s/*"]}
|
||||
]`, bucket))
|
||||
zero := iamAdminUser(t.Context(), t, globalIAMSys, "tagging-no-policy-"+mustGetUUID(), "")
|
||||
versionID := mustGetUUID()
|
||||
for _, method := range []string{http.MethodGet, http.MethodPut, http.MethodDelete} {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
tags []string
|
||||
cred auth.Credentials
|
||||
readStatus, writeStatus, wantStatus int
|
||||
wantWrites int32
|
||||
disabledFirst bool
|
||||
firstReadStatus int
|
||||
noReads bool
|
||||
}{
|
||||
{name: "deny-second-target", tags: []string{"false", "true"}, cred: conditional, wantStatus: http.StatusForbidden},
|
||||
{name: "deny-first-target", tags: []string{"true", "false"}, cred: conditional, wantStatus: http.StatusForbidden},
|
||||
{name: "all-authorized", tags: []string{"false", "false"}, cred: conditional, wantWrites: 2},
|
||||
{name: "anonymous", tags: []string{"false"}, wantStatus: http.StatusForbidden, noReads: true},
|
||||
{name: "invalid-credentials", tags: []string{"false"}, cred: auth.Credentials{AccessKey: root.AccessKey, SecretKey: "wrong-secret-key"}, wantStatus: http.StatusForbidden, noReads: true},
|
||||
{name: "zero-permissions", tags: []string{"false"}, cred: zero, wantStatus: http.StatusForbidden, noReads: true},
|
||||
{name: "zero-permissions-missing", tags: []string{"false"}, cred: zero, readStatus: http.StatusNotFound, wantStatus: http.StatusForbidden, noReads: true},
|
||||
{name: "unconditional-deny", tags: []string{"false"}, cred: denied, wantStatus: http.StatusForbidden, noReads: true},
|
||||
{name: "unreadable", tags: []string{"false"}, cred: root, readStatus: http.StatusServiceUnavailable, wantStatus: http.StatusServiceUnavailable},
|
||||
{name: "missing-version", tags: []string{"false", "false"}, cred: root, readStatus: http.StatusNotFound, wantStatus: http.StatusNotFound},
|
||||
{name: "tag-allow-missing-version", tags: []string{"false"}, cred: tagAllow, readStatus: http.StatusNotFound, wantStatus: http.StatusForbidden},
|
||||
{name: "tag-allow-lagging-first", tags: []string{"false", "false"}, cred: tagAllow, firstReadStatus: http.StatusNotFound, wantWrites: 1},
|
||||
{name: "tag-allow-lagging-second", tags: []string{"false", "false"}, cred: tagAllow, firstReadStatus: http.StatusOK, readStatus: http.StatusNotFound, wantWrites: 1},
|
||||
{name: "tag-allow-present-untagged", tags: []string{"false", ""}, cred: tagAllow, wantStatus: http.StatusForbidden},
|
||||
{name: "tag-allow-unreadable-first", tags: []string{"false", "false"}, cred: tagAllow, firstReadStatus: http.StatusServiceUnavailable, wantStatus: http.StatusServiceUnavailable},
|
||||
{name: "tag-allow-unreadable-second", tags: []string{"false", "false"}, cred: tagAllow, firstReadStatus: http.StatusOK, readStatus: http.StatusServiceUnavailable, wantStatus: http.StatusServiceUnavailable},
|
||||
{name: "disabled-target", tags: []string{"true", "false"}, cred: conditional, disabledFirst: true, wantWrites: 1},
|
||||
{name: "no-available-target", tags: []string{"false"}, cred: tagAllow, disabledFirst: true, wantStatus: http.StatusServiceUnavailable, noReads: true},
|
||||
{name: "skipped-target-cannot-mask-failure", tags: []string{"false", "false"}, cred: root, disabledFirst: true, writeStatus: http.StatusServiceUnavailable, wantStatus: http.StatusServiceUnavailable, wantWrites: 1},
|
||||
} {
|
||||
if method == http.MethodGet && tc.writeStatus != 0 {
|
||||
continue // This case only exercises a remote mutation failure.
|
||||
}
|
||||
t.Run(backend+"/"+method+"/"+tc.name, func(t *testing.T) {
|
||||
var reads, writes atomic.Int32
|
||||
globalBucketTargetSys.Lock()
|
||||
globalBucketTargetSys.targetsMap[bucket] = nil
|
||||
globalBucketTargetSys.Unlock()
|
||||
cfg := replication.Config{}
|
||||
for i, value := range tc.tags {
|
||||
readStatus := tc.readStatus
|
||||
if i == 0 && tc.firstReadStatus != 0 {
|
||||
readStatus = tc.firstReadStatus
|
||||
}
|
||||
target := newILMTestTarget(t)
|
||||
registerILMTestTarget(t, obj, bucket, target, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Query().Get("versionId") != versionID || !r.URL.Query().Has("tagging") || r.Header.Get(xhttp.MinIOSourceProxyRequest) != "true" {
|
||||
t.Errorf("remote request lost addressed version or proxy marker: %s", r.URL)
|
||||
}
|
||||
status := tc.writeStatus
|
||||
if r.Method == http.MethodGet {
|
||||
reads.Add(1)
|
||||
status = readStatus
|
||||
} else {
|
||||
writes.Add(1)
|
||||
if readStatus == http.StatusNotFound {
|
||||
t.Error("tagging write reached a missing replica")
|
||||
}
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
if status != 0 && status != http.StatusOK {
|
||||
w.WriteHeader(status)
|
||||
code := "ServiceUnavailable"
|
||||
if status == http.StatusNotFound {
|
||||
code = "NoSuchVersion"
|
||||
}
|
||||
fmt.Fprintf(w, "<Error><Code>%s</Code><Message>injected remote result</Message></Error>", code)
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
if value == "" {
|
||||
fmt.Fprint(w, "<Tagging><TagSet></TagSet></Tagging>")
|
||||
} else {
|
||||
fmt.Fprintf(w, "<Tagging><TagSet><Tag><Key>secret</Key><Value>%s</Value></Tag></TagSet></Tagging>", value)
|
||||
}
|
||||
case http.MethodDelete:
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
})
|
||||
globalBucketTargetSys.Lock()
|
||||
globalBucketTargetSys.targetsMap[bucket][i].Credentials = &madmin.Credentials{AccessKey: "proxy-test", SecretKey: "proxy-test-secret"}
|
||||
globalBucketTargetSys.arnRemotesMap[target.arn].Client.disableProxy = tc.disabledFirst && i == 0
|
||||
globalBucketTargetSys.Unlock()
|
||||
rule := configs[0].Rules[0]
|
||||
rule.Priority = i + 1
|
||||
rule.Destination = replication.Destination{ARN: target.arn, Bucket: target.bucket}
|
||||
cfg.Rules = append(cfg.Rules, rule)
|
||||
}
|
||||
meta, err := globalBucketMetadataSys.Get(bucket)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
meta.replicationConfig = &cfg
|
||||
globalBucketMetadataSys.Set(bucket, meta)
|
||||
body := ""
|
||||
if method == http.MethodPut {
|
||||
body = "<Tagging><TagSet><Tag><Key>new</Key><Value>value</Value></Tag></TagSet></Tagging>"
|
||||
}
|
||||
path := "/" + bucket + "/remote-only?tagging&versionId=" + versionID
|
||||
var req *http.Request
|
||||
if tc.cred.AccessKey == "" {
|
||||
req = httptest.NewRequest(method, path, strings.NewReader(body))
|
||||
} else {
|
||||
req, err = newTestSignedRequestV4(method, path, int64(len(body)), strings.NewReader(body), tc.cred.AccessKey, tc.cred.SecretKey, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
wantStatus := tc.wantStatus
|
||||
if wantStatus == 0 {
|
||||
wantStatus = http.StatusOK
|
||||
if method == http.MethodDelete {
|
||||
wantStatus = http.StatusNoContent
|
||||
}
|
||||
}
|
||||
wantWrites := tc.wantWrites
|
||||
if method == http.MethodGet {
|
||||
wantWrites = 0
|
||||
}
|
||||
if rec.Code != wantStatus || writes.Load() != wantWrites {
|
||||
t.Errorf("status=%d writes=%d, want status=%d writes=%d: %s", rec.Code, writes.Load(), wantStatus, wantWrites, rec.Body.String())
|
||||
}
|
||||
if tc.noReads && reads.Load() != 0 {
|
||||
t.Errorf("unauthorized request reached %d remote reads", reads.Load())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -29,11 +29,15 @@ healthcheck:
|
||||
retries: 5
|
||||
```
|
||||
|
||||
`ready` can return success before storage and IAM finish loading. It checks the process and, when configured, KMS and etcd; it does not certify S3 data access. Keep this bootstrap behavior so distributed peers can discover and connect to one another. Kubernetes headless peer services must retain `publishNotReadyAddresses: true`.
|
||||
|
||||
`cluster` checks initialization and reported write quorum, but a restarted node can still be reconnecting its remote drives after that check succeeds. Neither endpoint is a sufficient gate for an application that immediately reads or writes S3. Compose `depends_on: condition: service_healthy` inherits the image probe's limited meaning. Keep the node probe, and gate the dependent application with a bounded signed S3 check: write a uniquely named small object through each node, then read every acknowledged object through every node and verify its bytes and version. Retry within a fixed overall deadline with per-request timeouts, and clean up the probe objects. Failure should stop the rollout or dependent startup. This samples the tested keys and erasure sets; it does not prove spare failure capacity or that every drive is online. See the [startup window](#startup-readiness-window) below.
|
||||
|
||||
`silo healthcheck --maintenance cluster` answers the pre-drain question documented below: exit `0` when the node can be taken down safely, exit `1` (HTTP 412) when doing so would lose HA. Keep the `cluster` checks out of per-container liveness probes — they reflect cluster-wide quorum, not this process.
|
||||
|
||||
## Liveness probe
|
||||
|
||||
This probe always responds with '200 OK'. Only fails if 'etcd' is configured and unreachable. When liveness probe fails, Kubernetes like platforms restart the container.
|
||||
This probe returns `200 OK` while the process accepts requests, including before the object layer initializes. It can return `503` under request-queue saturation and does not check KMS or etcd. Kubernetes uses repeated liveness failures to restart the container, so configure its startup budget and failure threshold accordingly.
|
||||
|
||||
```
|
||||
livenessProbe:
|
||||
@@ -50,7 +54,7 @@ livenessProbe:
|
||||
|
||||
## Readiness probe
|
||||
|
||||
This probe responds with '200 OK' once the server process is up: it fails only while the request queue is overloaded, or when the health operation against the configured KMS or 'etcd' fails. When readiness probe fails, Kubernetes like platforms turn-off routing to the container.
|
||||
This probe responds with '200 OK' once the server process is up: it fails only while the request queue is overloaded, or when the health operation against the configured KMS or 'etcd' fails. It does not wait for storage or IAM to load, because a distributed node must be reachable by its peers before it can load them; the headless service of a StatefulSet should still publish not-ready addresses. When readiness probe fails, Kubernetes like platforms turn-off routing to the container.
|
||||
|
||||
```
|
||||
readinessProbe:
|
||||
@@ -72,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
|
||||
@@ -90,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
|
||||
@@ -108,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
|
||||
|
||||
@@ -76,7 +76,7 @@ require (
|
||||
github.com/minio/xxml v0.0.3
|
||||
github.com/minio/zipindex v0.5.0
|
||||
github.com/mitchellh/go-homedir v1.1.0
|
||||
github.com/nats-io/nats-server/v2 v2.15.1
|
||||
github.com/nats-io/nats-server/v2 v2.15.2
|
||||
github.com/nats-io/nats.go v1.55.0
|
||||
github.com/nats-io/stan.go v0.10.4
|
||||
github.com/ncw/directio v1.0.5
|
||||
@@ -122,8 +122,8 @@ require (
|
||||
aead.dev/minisign v0.3.0 // indirect
|
||||
cel.dev/expr v0.25.3 // indirect
|
||||
cloud.google.com/go v0.123.0 // indirect
|
||||
cloud.google.com/go/auth v0.24.1 // indirect
|
||||
cloud.google.com/go/auth/oauth2adapt v0.3.0 // indirect
|
||||
cloud.google.com/go/auth v0.24.3 // indirect
|
||||
cloud.google.com/go/auth/oauth2adapt v0.3.1 // indirect
|
||||
cloud.google.com/go/compute/metadata v0.10.0 // indirect
|
||||
cloud.google.com/go/iam v1.15.0 // indirect
|
||||
cloud.google.com/go/monitoring v1.32.0 // indirect
|
||||
@@ -255,9 +255,9 @@ require (
|
||||
github.com/muesli/reflow v0.3.0 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/nats-io/jwt/v2 v2.8.2 // indirect
|
||||
github.com/nats-io/jwt/v2 v2.8.3 // indirect
|
||||
github.com/nats-io/nats-streaming-server v0.25.6 // indirect
|
||||
github.com/nats-io/nkeys v0.4.16 // indirect
|
||||
github.com/nats-io/nkeys v0.4.17 // indirect
|
||||
github.com/nats-io/nuid v1.0.1 // indirect
|
||||
github.com/oklog/ulid/v2 v2.1.2 // indirect
|
||||
github.com/olekukonko/tablewriter v0.0.5 // indirect
|
||||
|
||||
@@ -9,10 +9,10 @@ cel.dev/expr v0.25.3 h1:A2jO8jwOugrrovveCWfj0KEZOfqiLgAcwjpHPhzIGw0=
|
||||
cel.dev/expr v0.25.3/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
|
||||
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
|
||||
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
|
||||
cloud.google.com/go/auth v0.24.1 h1:NTmd2iJb4DsGIav0KiqFMYI88nMshA2wnFMBatRmoCs=
|
||||
cloud.google.com/go/auth v0.24.1/go.mod h1:1dmBXa9xfBvBr39ZnDUDkaaA91ccq9CcqENDgJA4d7c=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.3.0 h1:FY8oSZpCYoUNv6QxVODuMjQz4IlSOVeiQtZ08vLPz88=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.3.0/go.mod h1:7+2uCm7++XFO+/lN06c2HXpDXb/NMNn2/UwyBPbTnkk=
|
||||
cloud.google.com/go/auth v0.24.3 h1:oVmRvR/pGSCNXjviwXCisJjRSEdQ0BlcOpW0uhuHLm8=
|
||||
cloud.google.com/go/auth v0.24.3/go.mod h1:p4UXqfSXnMVUDutk9xePzI8lj8yyoJ3BLsKWv0NSvQk=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.3.1 h1:BKLDdbWS7pafd820dsyFDF6fPEzIGqr7ny6hbTzCCgE=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.3.1/go.mod h1:B1nM4FJDxoDqAt3PXFG4IflUoQ5kvWAOOM3R+5wAszg=
|
||||
cloud.google.com/go/compute/metadata v0.10.0 h1:pyKMUQSwchgkIBBJGdILqQbs/BNJXqwSA7Ej6LAvvtY=
|
||||
cloud.google.com/go/compute/metadata v0.10.0/go.mod h1:rGFHRrIif570kSibjFTMbt6/4/tzgJWFGI/HVol4GIk=
|
||||
cloud.google.com/go/iam v1.15.0 h1:U/wqjfrM0ceBFdPlOI2+cu68yPAxh72M8fe9uLG0+ZE=
|
||||
@@ -487,18 +487,18 @@ github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/nats-io/jwt/v2 v2.8.2 h1:XXRgB60MSTnqsRwejQurVDs/hcv2dkt+86GjI+I/bMc=
|
||||
github.com/nats-io/jwt/v2 v2.8.2/go.mod h1:Ag/56sq9OblL4JgdYufDd16Egb17Kr/8WwwuO/forVc=
|
||||
github.com/nats-io/nats-server/v2 v2.15.1 h1:fK3AeYFIji1Smng60jB0hVoAPZezA/UzsaI3BqXFNEI=
|
||||
github.com/nats-io/nats-server/v2 v2.15.1/go.mod h1:yeuYRtKVKIFtmMz2/YPk+6hvmNBnk+WwTUzErBdH6IE=
|
||||
github.com/nats-io/jwt/v2 v2.8.3 h1:pqImTmlqH2zPHKNdRXO0YRyWif2diGRlrtLFJ2RxM2I=
|
||||
github.com/nats-io/jwt/v2 v2.8.3/go.mod h1:OOY2rP/UU8tcfoqhbHmBKcNdpFMKvmxIkmFFa16M4wI=
|
||||
github.com/nats-io/nats-server/v2 v2.15.2 h1:yLsgt2kslgymvxvjFq1WGWck5hkat2YYfbXvQ145tzU=
|
||||
github.com/nats-io/nats-server/v2 v2.15.2/go.mod h1:VXHCZgDglV5wO2B8ZE58yG2HzqxQxYUbQqibUh5Xcr8=
|
||||
github.com/nats-io/nats-streaming-server v0.25.6 h1:8OBRaIl64u+DFvZYpF50RRzwG/yLcJZL0R7VMc7tp4Y=
|
||||
github.com/nats-io/nats-streaming-server v0.25.6/go.mod h1:LEcu6uGSDBB4O/IBUsDBHYk/O0K7XZza8nMjCIXicLk=
|
||||
github.com/nats-io/nats.go v1.22.1/go.mod h1:tLqubohF7t4z3du1QDPYJIQQyhb4wl6DhjxEajSI7UA=
|
||||
github.com/nats-io/nats.go v1.55.0 h1:Ng8QKHdvdHIWoaiq0+d/aH1BWkxvcJxWJ0Y/9BPKx3M=
|
||||
github.com/nats-io/nats.go v1.55.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k=
|
||||
github.com/nats-io/nkeys v0.3.0/go.mod h1:gvUNGjVcM2IPr5rCsRsC6Wb3Hr2CQAm08dsxtV6A5y4=
|
||||
github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg=
|
||||
github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs=
|
||||
github.com/nats-io/nkeys v0.4.17 h1:JAJrlDhOyQqOt0a1eg4cbIZ1Kc7+vmkCigAltpwQpVA=
|
||||
github.com/nats-io/nkeys v0.4.17/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs=
|
||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
||||
github.com/nats-io/stan.go v0.10.4 h1:19GS/eD1SeQJaVkeM9EkvEYattnvnWrZ3wkSWSw4uXw=
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
apiVersion: v1
|
||||
description: S3-Interface Libre Object Storage
|
||||
name: silo
|
||||
version: 7.0.3
|
||||
appVersion: RELEASE.2026-09-16T00-00-00Z
|
||||
version: 7.0.4
|
||||
appVersion: RELEASE.2026-11-09T00-00-00Z
|
||||
keywords:
|
||||
- silo
|
||||
- storage
|
||||
|
||||
+17
-3
@@ -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
|
||||
|
||||
@@ -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://<External-IP>:9000. Follow the below steps to connect to Silo server with mc client:
|
||||
You can now access the Silo server on {{ $scheme }}://<External-IP>:{{ .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)@<External-IP>:{{ .Values.service.port }}
|
||||
2. mcli alias set external {{ $scheme }}://<External-IP>:{{ .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 }}
|
||||
|
||||
@@ -11,11 +11,11 @@ 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
|
||||
tag: RELEASE.2026-09-16T00-00-00Z
|
||||
tag: RELEASE.2026-11-09T00-00-00Z
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
imagePullSecrets: []
|
||||
@@ -25,7 +25,7 @@ imagePullSecrets: []
|
||||
##
|
||||
mcImage:
|
||||
repository: pgsty/mc
|
||||
tag: RELEASE.2026-10-26T00-00-00Z
|
||||
tag: RELEASE.2026-11-09T00-00-00Z
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
## Silo mode, i.e. standalone or distributed.
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user