Compare commits

..

83 Commits

Author SHA1 Message Date
Feng Ruohang 34ba7cc1ff Prepare SILO 20261112 release candidate
Harden IAM/bucket/import/replication policy validation, enforce tag and
version authorization across read/copy/list/archive endpoints, bind SigV4
credential-scope dates and streaming trailer limits, validate HTTP SSE-KMS
contexts as strict UTF-8 JSON, add release-commit and coordinated
dependency gates, and pin silo-pkg v3.16.1, Console v2.7.1, mcli 20261112,
Go 1.27.4, and Helm chart 7.0.5.

Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-11-12 03:06:15 +08:00
Feng Ruohang bae0ba0b75 docs(changelog): sync unreleased decommission, tagging and replication notes
Align unreleased server notes with main commits 293ab78b4 and 8587e061b and the completed site-replication topology snapshot, pooled copy rewrite, and batch KMS UTF-8 fixes.

Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-11-11 15:49:35 +08:00
Feng Ruohang cd3e46a2b7 fix(copy): preserve compression index on pooled copy rewrites
Forward dstOpts.IndexCB into erasureServerPools.CopyObject's PutObject options so multi-block S2 compressed copies (> compMinIndexSize) rewritten across pools persist Parts[0].Index for O(1) block-level ranged GET seeking.

Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-11-11 15:49:35 +08:00
Feng Ruohang ef6efd3120 fix(site-replication): snapshot state before bucket healing and peer hooks
Snapshot site-replication topology and release the state read lock before bucket healing, metadata healing, IAM deletion healing, and replication hooks perform storage I/O or peer RPCs. Guard remaining peer-state reads and cloning edits so concurrent configuration updates neither deadlock waiting writers nor race on state maps.

Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-11-11 15:43:41 +08:00
Feng Ruohang 4a9bdb5407 fix(batch): reject invalid UTF-8 before KMS context rotation
Reject contexts whose JSON serialization could change associated data or collapse distinct keys. Validate both submissions and persisted jobs before changing an object key; retain valid Unicode, including replacement characters.

Exercise malformed-key collisions, invalid values, manifest reload failures and readback with the real builtin KMS. Refs #346.

Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-11-11 15:30:11 +08:00
Feng Ruohang 2f228284f3 fix(replication): collect site status without holding topology locks
Snapshot peers before concurrent status collection and release the state lock before local metadata and IAM reads. Keep result indexing, peer credentials and errors tied to that snapshot; return a copied current peer state with cached IAM data.

Cover storage, remote requests, configuration changes, cancellation and cached peer state. Confirm the healing entry and all five helpers with deterministic writer probes under the race detector. Refs #341.

Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-11-11 15:30:11 +08:00
Feng Ruohang e36ea51d9c fix(copy): preserve authoritative content when pools are moving
Bind in-place metadata updates to the same version and pool as reads. Resolve encryption and checksum rewrites before write placement while retaining the SSE-C rewrap fast path and invalid self-copy checks.

Add HTTP and object-layer regressions for moving pools, duplicate bodies, null versions, timestamp ties and active-pool rewrites. Refs #343.

Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-11-11 15:30:11 +08:00
Feng Ruohang 3ce07a943b test(copy): cover KMS context and moving source pools
Exercise historical-version copies with real KMS associated data, SSE-C sources in draining pools, and SSE-KMS sources in rebalancing pools. Check destination placement and read all old and new versions.

Refs #343

Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-11-11 15:30:11 +08:00
Feng Ruohang 3c35cebe9e test: format attributes read-error regression
Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-11-11 15:30:11 +08:00
Feng Ruohang ae483baca1 docs: record unreleased repair and compatibility boundaries
Document archive authorization, explicit-version bulk-delete cost, migration progress, restore and copy repairs, and coordinated IAM and batch behavior without changing released history.

Fixes #348

Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-11-11 15:30:11 +08:00
Feng Ruohang 99fb47fbb9 fix(batch): align variable preflight with policy evaluation
Treat unsupported variable spellings as literals, inspect only condition values, and decode BinaryEquals before checking required context. Keep missing request variables fail-closed.

Fixes #347

Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-11-11 15:30:11 +08:00
Feng Ruohang 467ce760ed fix(batch): restore KMS context from persisted rotation options
Decode context without shared cached state for validation and execution, and isolate probe-only associated data. Cover manifest reload, retry and authenticated decryption after rotation.

Fixes #346

Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-11-11 15:30:11 +08:00
Feng Ruohang b5d97e3e1f fix(restore): ignore derived data-dir flag in revision identity
Keep restored versions consistent across expiry and successive heals while preserving metadata, layout and non-object revision distinctions.

Fixes #344

Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-11-11 15:30:11 +08:00
Feng Ruohang a8824b341f fix(copy): plan historical version placement before transforms
Locate exact versions for in-place updates and rewrite cross-pool references through the encryption and compression pipeline selected before copying.

Fixes #343

Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-11-11 15:30:11 +08:00
Feng Ruohang f746d07076 fix(auth): authorize attributes and archive reads with stored tags
Require both Attributes actions on the addressed version and GetObject permission for archive listings. Preserve conditional allows, missing-object masks and retryable storage errors.

Fixes #342

Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-11-11 15:30:11 +08:00
Feng Ruohang 745fbf0437 fix(site-replication): release heal read lock before hooks
Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-11-11 15:30:11 +08:00
Feng Ruohang 8587e061bb fix(decom): tolerate offline drives within erasure quorum
Verify migration progress and final source cleanup on every online drive, allowing disk-not-found failures within the configured read quorum. Preserve strict rejection of online metadata remnants, corruption and directory errors. Cover parent and child migration with an offline drive and reject outages beyond parity.

Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-11-11 13:39:38 +08:00
Feng Ruohang 293ab78b4c fix(auth): authorize tagging replicas without rejecting replication lag
Share the remote tag read and authorization path between GET and mutations. Authorize every present replica, fail closed on unreadable targets, and skip missing replicas unless all targets are absent. Gate remote reads on possible caller access and preserve retryable local read failures for DELETE tagging.

Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-11-11 13:39:19 +08:00
Feng Ruohang 67a90dfef4 fix(auth): authorize every tagging proxy destination
Read and authorize every selected remote version before any tagging mutation, and retain the inspected clients for the write phase. Count only attempted writes so disabled targets cannot mask remote failures. Preserve remote errors and missing-version behavior.

Cover PUT and DELETE across both erasure backends with divergent tags, invalid credentials, anonymous requests, unavailable reads, missing versions and skipped targets. Refs #337.

Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-11-11 11:47:56 +08:00
Feng Ruohang 62b8784428 fix: satisfy integrated server verifiers
Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-11-11 11:40:24 +08:00
Feng Ruohang 5dc4bd6a1d Fix pool migration cleanup and physical progress
Count removal only after every source drive confirms version absence, including free records and concurrently deleted parents. Retire successful candidates independently with the existing source, destination and tier ownership checks. Reject ambiguous raw reads, verify remaining metadata directly, and propagate cancellation.

Signed-off-by: Feng Ruohang <rh@vonng.com>
(cherry picked from commit b1a97c7e6cc7ab5f44110b5b8258afb60a50053e)
2026-11-11 11:25:32 +08:00
Feng Ruohang cf486df661 Fix Object Lock replica reconciliation boundaries
Reconcile CopyObject lock and tag revisions with the addressed destination,
including missing versions and self-referential copies. Preserve active
COMPLIANCE across pool orderings, lifecycle decisions and set-level writes.

Require retention authorization for source retention revisions, advance local
lock and tag revisions monotonically, and fail force-delete before option
parsing when bucket lock configuration cannot be read. Preserve authorized
same-content replication and existing encryption and tier cleanup behavior.

Signed-off-by: Feng Ruohang <rh@vonng.com>
(cherry picked from commit a08c3d149dcccad74dd68ab374c02f56379181be)
2026-11-11 11:20:19 +08:00
Feng Ruohang 1d714834c5 fix(iam): normalize service policies and preserve imported state
Keep omitted edit policies unchanged, reset valid empty policies to inheritance, and normalize nonempty policies without a version before storage. Preserve caller policy objects and exclude derived authorization claims from snapshots and new JWTs.

Restore imported service-account and group status, report the actual group error, and cover creation, editing, replication, import/export and raw JWT claims with focused server tests.

Refs #339, #345.

Signed-off-by: Feng Ruohang <rh@vonng.com>
(cherry picked from commit 86e4c09d47f48cb2f44fb2f63f02f222ac6ae102)
2026-11-11 11:17:55 +08:00
Feng Ruohang 14ecb222e7 fix(auth): secure proxied tagging and streaming trailers
Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-11-11 11:13:26 +08:00
Feng Ruohang e51bff35a3 Merge service-account replication fix into main
Include the remaining committed IAM snapshot fix from claude/lucid-hopper-811f3d. Preserve the current contributor inventory and previously integrated fixes while adding the service-account replication release note.

Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-11-11 00:12:08 +08:00
Feng Ruohang 20b3bfb6e7 Merge verified prerelease fixes into main
Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-11-11 00:08:25 +08:00
Feng Ruohang 322cd8d2c1 fix(decom): count lifecycle removals as scan progress
Reuse the source-version lookup to count versions removed by ILM before migration cleanup so nested keys are discovered on the next pass. Keep delete-marker-only expiry from reporting physical progress, and cover both the decommission regression and the removal-count boundary.

Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-11-11 00:08:07 +08:00
Feng Ruohang c3340ec7f5 fix(auth): scope unread tag candidates to effective policies
Select cached user, group, role and session policies without lazy IAM reads. Keep alternative tag values and Deny keys when combining candidates, and bound intermediate combinations as well as authorization attempts.

Cover unrelated cached policies, intersecting tag conditions, temporary credentials, root-derived credentials and cache-only lookup regressions for issue #293.

(cherry picked from commit 65acda0114)
Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-11-10 23:32:29 +08:00
Feng Ruohang 7c91dc17b2 Merge remote-tracking branch 'origin/main'
Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-11-10 23:31:32 +08:00
Feng Ruohang 2f93e0c6b3 Merge pull request #294 from botdeniska/fix/tag-auth-server-error
Preserve server errors for reads authorized by object tags and GetObjectAttributes.

Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-11-10 23:30:24 +08:00
Feng Ruohang 65acda0114 fix(auth): scope unread tag candidates to effective policies
Select cached user, group, role and session policies without lazy IAM reads. Keep alternative tag values and Deny keys when combining candidates, and bound intermediate combinations as well as authorization attempts.

Cover unrelated cached policies, intersecting tag conditions, temporary credentials, root-derived credentials and cache-only lookup regressions for issue #293.

Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-11-10 23:22:57 +08:00
Denis Babchuk c6949a38c2 fix(api): keep GetObjectAttributes server errors and check s3:ListBucket on the bucket
Signed-off-by: Denis Babchuk <babchukdenis@icloud.com>
(cherry picked from commit a2ff3c2ecd)
Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-11-10 22:33:13 +08:00
Denis Babchuk 80dd3e3ce1 test(api): cover GetObjectAttributes errors with and without s3:ListBucket
Signed-off-by: Denis Babchuk <babchukdenis@icloud.com>
(cherry picked from commit 67bc32f401)
Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-11-10 22:33:13 +08:00
Denis Babchuk 89028732ab fix(auth): keep the server error when a read allowed by object tags cannot read the tags
Signed-off-by: Denis Babchuk <babchukdenis@icloud.com>
(cherry picked from commit 3c4a6296f9)
Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-11-10 22:33:13 +08:00
Denis Babchuk 55bd906d30 test(auth): cover reads allowed by s3:ExistingObjectTag when the object cannot be read
Signed-off-by: Denis Babchuk <babchukdenis@icloud.com>
(cherry picked from commit afa35d16e9)
Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-11-10 22:33:13 +08:00
Feng Ruohang 296c9710b1 fix(security): close release blockers
Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-11-10 21:51:47 +08:00
Feng Ruohang f3b42b0eb8 Merge pull request #335 from botdeniska/fix/decom-keys-below-a-key
fix(decom): repeat the walk of a bucket while it removes source versions

Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-11-10 21:30:18 +08:00
Feng Ruohang da8d8a54b1 fix(iam): replicate a service account as the record that is stored
Site replication sent an edit of a service account as the fields the
request changed. A peer orders the items of an account by revision and
drops an older one, which is right for a whole account and loses data for
a part of one: when an edit did not reach a peer, or reached it after a
later edit, the peer kept the old secret key, status or inline policy
under the revision of the later edit, and healing took the sites for
equal. Two requests at one site sufficed, as the sender dropped the item
of the first when the second was already stored.

The first sync of a new site, healing and IAM export put an account
together from two places: the revision from storage, and the claims and
the inline policy from the cache of the server; healing took the secret
key and the status from the cache as well. A server whose cache had not
heard of an edit made through another server sent the account from
before the edit under the revision of the edit, and a peer kept that.

replicationItem now sends a create or an edit as the account stored at
that time: the whole record, read once, with its revision, as it does
for a group. serviceAccountSnapshot builds it from the record and the
token in it; IAM export uses the same function, and sync, heal and the
update handler only name the account. The wire types and the way a peer
applies an item do not change: a peer on 20261109 or 20261026 takes such
an edit as it takes a healed account, which also means that it keeps the
inline policy on an edit that names none. A site that is not upgraded
still sends its edits as before.

The hook reads what it sends before it takes the lock of the replication
configuration: loading an identity takes that lock for reading, and a
read lock asked for again behind a waiting writer is never granted. An
account whose stored token cannot be read is an error, as it was for sync
and heal, and is not taken for deleted.

A new service account of a user that was deleted and created again now
reaches the peers without waiting for healing: the create carries the
claims of the token, among them the parent revocation a peer asks for.

An edit gives every site the whole account of the site that took it.
Accounts that already differ between sites under one revision are not
repaired by this change, only by their next edit.

Signed-off-by: Feng Ruohang <rh@vonng.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 20:19:54 +08:00
Feng Ruohang 2b62df876a docs(contributors): cite the real CVE-2026-39414 follow-up commit
Regenerated from the contributor data on silo.pgsty.com. One note named
5dc705936, which is not a commit in any repository; the June follow-up
is fd69c89d0.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-11-10 17:24:28 +08:00
Feng Ruohang 518915d241 docs(contributors): credit upstream advisory researchers and fix a commit link
Regenerated from the reviewed contributor data on silo.pgsty.com. The roll
grows from 76 to 81 people: it now names the researchers credited on the
upstream advisories for inherited CVEs. The link to the Helm chart 7.0.4
commit carried a full hash that matches no commit; it now points at
47275042f.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-11-10 16:57:15 +08:00
Denis Babchuk ce5d191077 fix(decom): repeat the walk of a bucket while it removes source versions
Signed-off-by: Denis Babchuk <babchukdenis@icloud.com>
2026-11-10 11:48:42 +03:00
Denis Babchuk b404f78764 test(decom): cover keys below a key that the walk does not see
Signed-off-by: Denis Babchuk <babchukdenis@icloud.com>
2026-11-10 11:48:42 +03:00
Feng Ruohang d138996983 docs(helm): fix NOTES connection commands and document 20261109 upgrade gates
Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-11-10 14:41:49 +08:00
Feng Ruohang d52ba0290a docs: align release, TLS, healthcheck, and contributor docs with 20261109
Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-11-10 14:41:49 +08:00
Feng Ruohang 8f3657dc52 Merge pull request #333 from pgsty/claude/cross-bucket-path-traversal-delete-5e1fc2
fix(pools): keep rejected names out of a single-pool bulk delete
2026-11-10 14:39:36 +08:00
Feng Ruohang 7efdedd5ba Merge pull request #329 from pgsty/deps/console-2.7.0
deps: pin SILO Console 2.7.0
2026-11-10 14:30:02 +08:00
Feng Ruohang a18293d0c3 fix(pools): keep rejected names out of a single-pool bulk delete
DeleteObjects checks every name with checkDelObjArgs, but a single pool
still sent all entries to the drives and cleared the error of an entry
that a drive reported as found. Local drives join the name to the bucket
directory, so a name with a ".." segment reached another bucket or the
meta bucket. An S3 DeleteObjects caller allowed to delete in one bucket
could remove a version elsewhere, including one under Object Lock
retention, and from a versioned bucket could write a delete marker on an
object of another bucket or on stored bucket metadata. The bulk lock was
taken on the normalized name as well, that is on the other object.

Entries that fail the check now keep their error, and the rest of the
request runs as a request of its own, before any lock or lookup. A
request whose names are all valid does not enter the new code. More than
one pool already skipped such entries.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-11-10 14:24:35 +08:00
Feng Ruohang 032ac49061 Merge pull request #332 from pgsty/claude/multi-pool-ilm-version-dup-210154
fix(ilm): count a version under old and healed headers as one revision
2026-11-10 14:14:32 +08:00
Feng Ruohang 244de7ecc1 Merge pull request #331 from pgsty/claude/svcacct-inline-policy-bug-961eaa
fix(iam): keep a service account's inline policy on an edit that names none
2026-11-10 14:12:36 +08:00
Feng Ruohang 63650a31a1 fix(iam): keep a service account's inline policy on an edit that names none
An update-service-account request without a policy removed the inline
policy of the account: the store took a nil session policy for an empty
one, and the handler turned an empty one into nil, so "not given" and
"given, empty" were one case. A status change, a secret key rotation, a
rename or a new expiry (mcli admin user svcacct disable, enable, and edit
without --policy, and the idp ldap accesskey equivalents) signed the token
again as inherited-policy, and the account then had all its parent is
allowed. A replication peer did the same with the forwarded edit.

A nil session policy now leaves the policy as it is. The handler passes an
empty policy document on as such, and only that removes the inline policy,
as mcli admin user svcacct edit --policy with {"Statement":[]} already
asks for. A JSON null policy counts as none. The peer handler passes nil
for an edit without a policy and needs no change.

What an update does with any other policy document is unchanged.

The behaviour came with upstream f0462322f (minio/minio#14847).

Signed-off-by: Feng Ruohang <rh@vonng.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 13:47:09 +08:00
Feng Ruohang 36c8d1683a fix(ilm): count a version under old and healed headers as one revision
The version history that an ILM expiry reads accepts a version only
when one revision of it is on more than half of the drives and on its
read quorum, and a revision was the header as the drive stores it plus
a digest of the metadata. A drive holds the header that the release
which wrote the version there stored: before RELEASE.2024-04-28
without erasure coding, and until RELEASE.2022-01-25 with a signature
that counted empty part ETags. A heal writes the header of its own
release and leaves the other drives alone. Reads and heal take both
for one version; the history counted two revisions. With 5 to 11 of 16
drives healed (EC:4) neither had the quorum, objectVersions failed
with a read quorum error, and every expiry that reads the history was
refused on each scanner cycle, with nothing to bring the drives
together: every expiry in a versioned bucket with several pools, and
an expiry of all versions of a key with one pool too.

versionRevision now gives the key of an object version the signature
and the erasure coding as the current release computes them from the
decoded metadata. The key of a version this release wrote carries its
header as stored, so its history decides as before, including the
order of versions of one time, which the signature breaks. The digest
covers what both fields say. The stored flags stay in the key: whether
a transitioned version uses its data directory follows the moment a
drive wrote the entry (fileInfoRevisions).

TestPoolsLifecycleHeaderEras accepts both old headers on every count
of healed drives. TestPoolsLifecycleHealedOldHeaders expires a
noncurrent version with two pools, and all versions with one, after
half of the drives were wiped and healed. Both fail before this
change. Drives that differ only in the data directory flag of a
restored version are still two revisions; that is a separate defect.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-11-10 13:45:37 +08:00
Feng Ruohang c11bfda8f4 fix(iam): keep a service account's inline policy on an edit that names none
An update-service-account request without a policy removed the inline
policy of the account: the store took a nil session policy for an empty
one, and the handler turned an empty one into nil, so "not given" and
"given, empty" were one case. A status change, a secret key rotation, a
rename or a new expiry (mcli admin user svcacct disable, enable, and edit
without --policy, and the idp ldap accesskey equivalents) signed the token
again as inherited-policy, and the account then had all its parent is
allowed. A replication peer did the same with the forwarded edit.

A nil session policy now leaves the policy as it is. The handler passes an
empty policy document on as such, and only that removes the inline policy,
as mcli admin user svcacct edit --policy with {"Statement":[]} already
asks for. A JSON null policy counts as none. The peer handler passes nil
for an edit without a policy and needs no change.

What an update does with any other policy document is unchanged.

The behaviour came with upstream f0462322f (minio/minio#14847).

Signed-off-by: Feng Ruohang <rh@vonng.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 11:55:28 +08:00
Feng Ruohang 6a65b43076 docs(helm): fix NOTES connection commands and document 20261109 upgrade gates
Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-11-10 11:00:05 +08:00
Feng Ruohang 3269d2214e docs: align release, TLS, healthcheck, and contributor docs with 20261109
Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-11-10 11:00:05 +08:00
Feng Ruohang b3e69bca2f fix(pools): keep rejected names out of a single-pool bulk delete
DeleteObjects checks every name with checkDelObjArgs, but a single pool
still sent all entries to the drives and cleared the error of an entry
that a drive reported as found. Local drives join the name to the bucket
directory, so a name with a ".." segment reached another bucket or the
meta bucket. An S3 DeleteObjects caller allowed to delete in one bucket
could remove a version elsewhere, including one under Object Lock
retention, and from a versioned bucket could write a delete marker on an
object of another bucket or on stored bucket metadata. The bulk lock was
taken on the normalized name as well, that is on the other object.

Entries that fail the check now keep their error, and the rest of the
request runs as a request of its own, before any lock or lookup. A
request whose names are all valid does not enter the new code. More than
one pool already skipped such entries.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-11-10 10:25:08 +08:00
Feng Ruohang d998292387 Merge pull request #330 from pgsty/codex/release-20261109-closeout
Enforce Object Lock during S3 bulk version deletion and clarify upgrade readiness.
2026-11-10 00:09:45 +08:00
Feng Ruohang 6ef3a52fc3 Enforce Object Lock during S3 bulk version deletion
Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-11-09 23:53:43 +08:00
Feng Ruohang e21b1c3a71 docs(healthcheck): say what ready does not wait for
The ready probe turns healthy before storage and IAM load, by design, so
that distributed nodes can reach each other during startup. Point
startup ordering such as Compose service_healthy at the cluster check,
and keep publishing not-ready addresses for StatefulSet peers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-11-09 22:07:59 +08:00
Feng Ruohang 86dc6ee2e2 fix(restore): return from a failed restore without waiting for its cleanup
A failed or canceled native restore cleared its ongoing marker under a
fresh one-minute deadline, while the restore itself could still hold the
object lock that the cleanup waits for. The request then stalled for
about a minute (60 s in TestNativeRestoreWithoutOuterLock and
TestRestoreRecoversStrandedMarker).

Try the cleanup for 100 ms and leave the rest to the bounded background
retries, which already exist for the pools path and now also serve the
single-set path. Each retry still takes the object lock afresh and clears
only the marker of its own attempt. The CI restore race step drops from
about 260 s to 37 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-11-09 22:07:59 +08:00
Feng Ruohang 0a238589a4 ci: gate releases on the new Object Lock delete tests
The release workflow keeps its Object Lock race gate aligned with the
matching step in go.yml. Add the replicated-delete and directory bulk
delete tests there too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-11-09 21:26:02 +08:00
Feng Ruohang 1f731549e4 docs: prepare SILO 20261109
Record RELEASE.2026-11-09T00-00-00Z (package 20261109000000.0.0) in the
changelog: the fixes since 20261026, Console v2.7.0, mcli 20261109 and
silo-pkg v3.16.0, upgrade notes and known issues. Point the README quick
starts at the new image, replace the 20261026 release section with the
20261109 upgrade notice in both languages, refresh the contributor roll
to 2026-11-09, and add a second security contact.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-11-09 21:05:22 +08:00
Feng Ruohang 368c52f4dc build(deps): embed the published Console v2.7.0
Console v2.7.0 was cut again at 4e91ae506e70 with the review fixes that
followed 26ce922424f1. Select that commit; no other module changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-11-09 21:02:16 +08:00
Feng Ruohang f0a4d76ef8 Merge #328: Console 2.7, mcli 20261109 and silo-pkg 3.16
Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-11-09 21:00:20 +08:00
Feng Ruohang 2c06b28dbb deps: pin SILO Console 2.7.0
Pin the Console module to the v2.7.0 tag commit (4e91ae506e70), published
on 2026-11-09. It adds ten UI languages with Arabic right-to-left layout,
fixes usage-worker TLS, scheduling and shutdown, serves folder sizes over
plain HTTP, and fixes the container CA bundle and /tmp handling.

Console 2.7.0 requires silo-pkg v3.16.0 and builds with the mcli
RELEASE.2026-11-09T00-00-00Z source (pgsty/mc e1d4cc5abfe0). Go ignores
replacements declared by dependencies, so copy Console's single mc
replacement here and raise the silo-pkg requirement to match. silo-pkg
v3.16.0 changes only a policy comment and dependency floors.

Minimal version selection also raises klauspost/cpuid/v2 to v2.4.2,
go.yaml.in/yaml/v3 to v3.0.6, go-jose/v4 to v4.1.9, go-openapi/runtime to
v0.33.4 and tidwall/gjson to v1.20.1. minio-go stays at 320af050c445.
CREDITS is unchanged after regeneration.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-11-09 20:49:43 +08:00
Feng Ruohang aa2d79daf2 fix(object-lock): check a directory object's lock before a bulk delete
A directory object always has the null version. DeleteObjects without a
version ID chose whether to read the object before it turned the version
into null, so with Object Lock and no replication or tiering it checked
retention against empty metadata and removed a directory object under
legal hold or retention. Choose the null version before the lookup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-11-09 19:25:33 +08:00
Feng Ruohang 7e9ee6befc fix(replication): keep Object Lock on a replicated delete
A trusted replica DELETE skipped the destination's retention check for
every request, not only for the delete marker it is meant to create. A
caller allowed s3:ReplicateDelete (root, or the replication account)
could purge a version under legal hold or unexpired retention, or name
a stored version in a delete marker, which replaces that version.

Skip the check only for a delete-marker creation, whose version is not
yet stored here, and make that request refuse to replace a protected
version with RefuseLockedOverwrite, as batch replication pulls do; the
pools layer applies it to the version in any pool. A replicated purge
goes through the normal retention check. A source purge that bypassed
GOVERNANCE retention is now refused by a target whose copy still
carries it; the source records the purge as failed and retries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-11-09 19:13:21 +08:00
Feng Ruohang 47275042f6 chore(helm): deploy SILO and mcli 20261109 by default
The 20261026 release moved mcImage.tag but left image.tag, which the
templates deploy, at 20260916 (#296). Point the server and mcli images
and appVersion at RELEASE.2026-11-09T00-00-00Z and bump the chart to
7.0.4.

The Helm check now reads the chart version instead of a fixed file name,
and fails when image.tag differs from appVersion, so a release cannot
move one without the other again.

Upgrades that pass --reuse-values keep the old image.tag; set image.tag
explicitly when upgrading such a release.

Fixes #296

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-11-09 19:03:32 +08:00
Feng Ruohang d66b3889cd ci: give the native restore race tests room to finish
The lifecycle expiry and native restore race step takes about 260 s on
an M-series Mac against its 5 minute limit, and the job holding it was
already near 9 of its 15 minutes before that step was added. GitHub's
hosted runners are slower, so allow the step 10 minutes and the job 25,
as the other jobs have.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-11-09 19:03:32 +08:00
Feng Ruohang 1f579afeed Merge origin/main: conditional multipart initiation (#292)
Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-11-09 13:39:10 +08:00
Feng Ruohang b14dc454fb Merge origin/main: one trust store for both server images, mcli notices (#295)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-11-09 09:47:04 +08:00
Feng Ruohang bb31c26183 fix: close IAM loading and batch recovery review gaps
Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-11-08 18:52:50 +08:00
Feng Ruohang 07ce95dfda fix(iam): protect stale creates and cache loads
Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-11-08 14:19:49 +08:00
Feng Ruohang 7eed95bc8e fix(iam): drop a stale cached identity when its edit is refused
70914759e refused an edit whose stored record no longer matched the
caller's view, but left the stale copy in the serving server's cache in
two cases: a service-account edit bound to a parent whose account was
deleted and created again for another parent, and a replicated edit older
than what is stored, which site replication acknowledges. That server
kept authenticating the old credential until a notification or the
periodic load.

Both refusals now drop the cache entry of the addressed kind, as a
refusal for an absent or deleted identity does: the service-account
closure reports such a record as gone (callers still get
errNoSuchServiceAccount), and editStoredIdentity also drops the entry on
errIAMStaleUpdate. The LDAP group refresh again treats a stale update as
a refusal that writes nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-11-08 14:18:41 +08:00
Feng Ruohang 70914759e5 fix(iam): edit users and service accounts on the stored record, not a cached copy
UpdateServiceAccount, SetUserStatus and UpdateUserSecretKey read the
identity from the serving server's cache, changed it and wrote it back
whole. On a server that missed a notification, an ordinary edit (a
description, a status, a secret) undid a rotation made through another
server and brought back a user or service account deleted meanwhile, with
its old secret.

Edits now go through iamIdentityEdit, inside the identity's cross-node
revision lock: the stored record is loaded and normalized as the loaders
do (access key from the path, claims from the token, a deprecated comment
for an empty description), the edit applies to it, and an absent or
deleted identity is refused and dropped from the cache of the server
that tried. The LDAP group refresh of a0de92fcb uses the same mechanism.

- The admin handler binds a service-account edit to the parent it
  authorized: an account deleted and created again for another parent
  meanwhile is not edited (parentUser in updateServiceAccountOpts;
  replication does not set it).
- A replicated edit older than a deletion this site applied is answered
  as stale, so site replication acknowledges it instead of retrying.
- SetUserStatus refuses a service or temporary credential as before, and
  a refused edit drops only a cache entry of the addressed kind.
- A session-policy edit stores the claims of the token it signs; the
  token is signed once.
- UpdateUserSecretKey addressed to a service-account key finds no user
  instead of writing the account to the regular-user path.

Tests use a second IAM store over the same storage whose cache missed the
change: rotation, deletion, status and secret edits of users, parent
binding, legacy records, replicated edits around a deletion, and status
calls on a service account. Against the cache-based writers every case
fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-11-08 13:56:04 +08:00
Feng Ruohang a0de92fcbe fix(iam): refresh LDAP groups on the stored credential, not a stale snapshot
The hourly LDAP group refresh, which every server runs, reads the STS and
service-account credentials from its own cache, queries the directory,
and wrote each credential whose groups changed back whole through
UpdateUserIdentity. A secret rotation or an edit made meanwhile, on this
server or one whose notification it missed, was undone, and a credential
deleted meanwhile came back enabled with its old secret.

RefreshUserGroups replaces that writer, whose only caller was the
refresh. Under the identity's cross-node revision lock it loads the
stored record, refuses one that is absent, deleted, expired or now has
another parent (whose groups the lookup did not compute), and otherwise
changes only the groups of the stored record. A record without its
access key gets it from the path, as the loaders do. A refused refresh
drops the stale entry from the cache, which loads what is stored on next
use; a written or unchanged one caches the stored record. An STS record
keeps the lease of its token's remaining lifetime.

Tests cover rotation and edit, deletion, a sibling server whose cache did
not hear of a rotation or a deletion, recreation under the same and under
another parent, unchanged groups, and a stored record without its access
key. Written back as the old snapshot write, every case fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-11-08 12:00:20 +08:00
Feng Ruohang af0afd2321 test(batch): pin the parity of the tiered purge transition helper
TestBatchExpirePurgeRefusesTieredKey marks a version transitioned through
DecomTieredObject, which takes its parity from the global storage class.
Run after tests that leave a parity the one-drive fixture cannot have,
it wrote on no drive and still returned nil, and the purge then removed
the key. The helper now sets the set's own parity for the test and reads
the transition back from every drive.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-11-08 11:39:03 +08:00
Feng Ruohang 633c9bfe09 fix(batch): keep a job across edits that keep its credential
The submitter binding compared the UpdatedAt of the identity record, so
any rewrite of the record ended every running job of the credential with
"changed since submission": a service account's description, name or
later expiry, and the hourly LDAP group refresh of every LDAP-backed
service account and STS credential whose groups changed.

Compare only what identifies the credential: its type, access key and
parent, the revocation boundary of the record, and the keyed binding of
its secret and session token. Status, expiry, groups and policies are
read again on every start and action. A deletion advances the
revocation boundary, so a user created again under the access key with
the same secret still fails the binding, and adding an expiry to a
service account turns it into a temporary credential, which still ends
the job. UpdatedAt stays in the binding for diagnosis.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-11-08 11:39:03 +08:00
Feng Ruohang 18e09ca219 fix(batch): refuse a job only for a Deny that needs request context
A batch job is authorized without a request, so a policy statement that
depends on request context the job does not have (source address,
transport, headers) was taken to refuse the action. Two parts of that
rule refused jobs that no request would refuse:

- The scan for unsupplied policy variables ran over the whole statement
  before its resources were matched. A Deny on an unrelated bucket whose
  condition value held a variable such as ${aws:UserAgent} refused every
  job of the user. Resources are now matched first; a variable in a
  resource still means the statement may apply.
- An Allow that needs absent context, such as an IP-restricted read,
  refused the job even when another Allow granted the action. Such an
  Allow now grants nothing to the job: it is dropped from the policy that
  is evaluated, not merely exempted, since IfExists and Null conditions
  hold for an absent key and would grant. Dropping an Allow can only
  narrow the decision. A Deny that needs the context still refuses.

The narrowed policy is a new policy.Policy, because the statement index
of the original points into its own statements and the original may be a
cached document. For a session policy, whether one applies and its first
decision still come from the claims as before; only an allow is
re-evaluated on the narrowed policy.

Tests cover an unrelated Deny with a variable, a Deny with an unsupplied
variable in its resource, an IP-restricted Allow next to a plain one, a
Null Allow that plain evaluation without the address would grant, and
the same for session policies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-11-08 11:39:01 +08:00
Feng Ruohang a6571a53e9 chore(batch): keep the policy snapshot out of msgp generation
batchPolicySnapshot holds the IAM store, a context and a mutex. Without
an ignore directive, go generate emitted msgp encoders for it that do not
compile, so make check-gen (and the check-gen step of CI) failed on the
generated batch-authz files. The committed generated files are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-11-08 11:38:59 +08:00
Denis Babchuk a2ff3c2ecd fix(api): keep GetObjectAttributes server errors and check s3:ListBucket on the bucket
Signed-off-by: Denis Babchuk <babchukdenis@icloud.com>
2026-11-07 11:43:31 +03:00
Denis Babchuk 67bc32f401 test(api): cover GetObjectAttributes errors with and without s3:ListBucket
Signed-off-by: Denis Babchuk <babchukdenis@icloud.com>
2026-11-07 11:43:31 +03:00
Denis Babchuk 3c4a6296f9 fix(auth): keep the server error when a read allowed by object tags cannot read the tags
Signed-off-by: Denis Babchuk <babchukdenis@icloud.com>
2026-11-07 11:43:30 +03:00
Denis Babchuk afa35d16e9 test(auth): cover reads allowed by s3:ExistingObjectTag when the object cannot be read
Signed-off-by: Denis Babchuk <babchukdenis@icloud.com>
2026-11-07 11:43:29 +03:00
136 changed files with 14786 additions and 1580 deletions
+5 -4
View File
@@ -35,7 +35,8 @@ jobs:
- name: Check gofmt
run: |
mapfile -t unformatted < <(gofmt -l main.go cmd internal \
buildscripts/rebrand-guard buildscripts/helm-migration-guard)
buildscripts/rebrand-guard buildscripts/helm-migration-guard \
buildscripts/release-dependency-guard)
if [ "${#unformatted[@]}" -ne 0 ]; then
echo "The following files are not gofmt-formatted:"
printf '%s\n' "${unformatted[@]}"
@@ -78,7 +79,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 +106,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 +128,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:
+18 -2
View File
@@ -20,6 +20,7 @@ permissions:
id-token: write
attestations: write
artifact-metadata: write
actions: read
concurrency:
group: release-${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}
@@ -74,7 +75,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:
@@ -109,14 +110,29 @@ jobs:
{
echo "RELEASE_TAG=${TAG}"
echo "RELEASE_COMMIT=${HEAD_COMMIT}"
echo "PKG_VERSION=${PKG_VERSION}"
echo "LDFLAGS=${LDFLAGS}"
} >> "${GITHUB_ENV}"
echo "Release tag: ${TAG}"
echo "Release commit: ${HEAD_COMMIT}"
echo "Package version: ${PKG_VERSION}"
echo "LDFLAGS: ${LDFLAGS}"
- name: Check release commit is the tip of main with green CI
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
buildscripts/check-release-commit.sh "${RELEASE_COMMIT}"
- name: Verify coordinated release dependencies
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GOWORK: "off"
run: go run ./buildscripts/release-dependency-guard
- name: Check existing release state
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -140,7 +156,7 @@ jobs:
- name: Build Draft release with GoReleaser
uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3
with:
version: v2.18.3
version: v2.20.1
args: release --clean --skip=validate --config .github/goreleaser.yml
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+11 -2
View File
@@ -25,6 +25,9 @@ on:
- "buildscripts/minio-upgrade.sh"
- "buildscripts/sign-release-rpms.sh"
- "buildscripts/verify-build-provenance.sh"
- "buildscripts/check-release-commit.sh"
- "buildscripts/check-release-commit_test.sh"
- "buildscripts/release-dependency-guard/**"
- "buildscripts/check-release-state.sh"
- "buildscripts/check-release-state_test.sh"
- "buildscripts/verify-rebrand.sh"
@@ -97,7 +100,7 @@ jobs:
- name: GoReleaser config check
uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3
with:
version: v2.18.3
version: v2.20.1
args: check --config .github/goreleaser.yml
- name: Validate Helm chart and legacy upgrade identity
@@ -111,7 +114,7 @@ jobs:
- name: Build snapshot artifacts
uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3
with:
version: v2.18.3
version: v2.20.1
# A pull-request snapshot has no trusted release identity. Exercise
# the SBOM/checksum pipeline here, and reserve keyless signing for
# the tag-triggered release workflow with GitHub OIDC.
@@ -537,6 +540,8 @@ jobs:
bash -n buildscripts/minio-upgrade.sh
bash -n buildscripts/sign-release-rpms.sh
bash -n buildscripts/verify-build-provenance.sh
bash -n buildscripts/check-release-commit.sh
bash -n buildscripts/check-release-commit_test.sh
bash -n buildscripts/check-release-state.sh
bash -n buildscripts/check-release-state_test.sh
bash -n buildscripts/verify-rebrand.sh
@@ -553,6 +558,8 @@ jobs:
test -x buildscripts/package-release.sh
test -x buildscripts/sign-release-rpms.sh
test -x buildscripts/verify-build-provenance.sh
test -x buildscripts/check-release-commit.sh
test -x buildscripts/check-release-commit_test.sh
test -x buildscripts/check-release-state.sh
test -x buildscripts/check-release-state_test.sh
test -x buildscripts/verify-rebrand.sh
@@ -561,4 +568,6 @@ jobs:
test -x buildscripts/package/preremove.sh
test -x buildscripts/package/lifecycle_test.sh
test -x dockerscripts/docker-entrypoint_test.sh
buildscripts/check-release-commit_test.sh
GOWORK=off go test ./buildscripts/release-dependency-guard
buildscripts/check-release-state_test.sh
+319 -5
View File
@@ -1,11 +1,288 @@
# Changelog
## Unreleased
## RELEASE.2026-11-12T00-00-00Z
- Embed Console 2.7.0 source `26ce922424f1`, including its ten-language UI,
rebuilt frontend assets, Swagger array-validation fixes and Redoc routing.
Select the published mcli 20261109 source `e1d4cc5abfe0` and silo-pkg
v3.16.0 directly in the maintained dependency graph.
Package version: `20261112000000.0.0`. Publication status and artifacts are
listed in [GitHub Releases](https://github.com/pgsty/silo/releases). This
section covers the changes after the published 20261109 source `d998292387cc`,
adopting Go `1.27.4`, `silo-pkg v3.16.1`, embedded Console `v2.7.1`, and
bundled `mcli 20261112`. See the
[server compatibility notes](https://silo.pgsty.com/compatibility/server/)
for behavior and upgrade boundaries.
- Release checks verify the selected pkg and embedded Console versions and
match the bundled mcli release to its source module, independently of local
workspaces, before building release artifacts. Deploy SILO and mcli
`RELEASE.2026-11-12T00-00-00Z` by default from Helm chart `7.0.5`.
- Replica writes and copies use the destination version's retention, legal
hold and tags under its mutation lock, verify staged replica content and
SSE-C/compressed metadata against stored locked versions before committing,
and prevent newer peer timestamps from weakening unexpired COMPLIANCE
retention. Cross-pool reconciliation preserves COMPLIANCE protection
regardless of pool order, while authorized changes to GOVERNANCE retention
and legal hold remain possible. Local lock revisions advance past stored
revisions, unaddressed deletes under versioning suspension or excluded
prefixes enforce Object Lock across single-pool and multi-pool deployments
and fail closed when versioning or Object Lock metadata cannot be read,
bucket metadata imports require versioning across prefix exclusions as well
as suspension, and peer replication-trust headers must be signed with
header-based SigV4 ([#336](https://github.com/pgsty/silo/issues/336)).
- Tagging proxies (`GET`, `PUT` and `DELETE`) authenticate and authorize every
present replica, including its stored tags, fail closed on unreadable targets,
and skip missing replicas caused by replication lag unless all targets are
absent; active-active `GET` and `HEAD` proxies enforce `s3:ExistingObjectTag`
and `s3:versionid` before returning peer responses and forward
`X-Amz-Tagging-Directive: ACCESS` on `HEAD`. Authenticated unsigned-trailer
uploads enforce the request time window and header signatures, SigV4 header,
presigned, streaming and POST policy verifiers bind the credential-scope date
to the signed request timestamp, streaming trailer readers bound trailer memory
and surface non-EOF stream errors without hanging on negative
`X-Amz-Decoded-Content-Length`, and bucket metadata cache loads no longer
overwrite fresher concurrent updates
([#337](https://github.com/pgsty/silo/issues/337),
[#338](https://github.com/pgsty/silo/issues/338)).
- Object tag and version authorization now applies consistently across read,
copy, list and archive endpoints: `GetObject`, `HeadObject` and
`SelectObjectContent` evaluate `s3:ExistingObjectTag` and `s3:versionid`
(preserving retryable `5xx` server errors for callers authorized by tag
conditions when storage is unreadable, resolving the 20261109 limitation in
[PR #294](https://github.com/pgsty/silo/pull/294)); `CopyObject` and
`UploadPartCopy` authorize source `s3:ExistingObjectTag` and `s3:versionid`
as well as destination `s3:RequestObjectTag` on copied tags; `X-Amz-Tagging`
is returned on `GET`/`HEAD` (`X-Amz-Tagging-Directive: ACCESS`) only when
`s3:GetObjectTagging` / `s3:GetObjectVersionTagging` (or `s3:ReplicateTags`)
permits; `ListObjectsV2` / `ListObjectVersions` with `metadata=true` evaluate
current-object and per-version metadata permissions respectively, using stored tags; and `GetObjectAttributes`,
tagging handlers (`GET`/`PUT`/`DELETE`) and ZIP `GET`/`HEAD`/member listings
authorize stored tags and version actions without mutating unindexed archives
([#342](https://github.com/pgsty/silo/issues/342)).
- Policy parsing rejects unknown JSON fields, trailing tokens and malformed
principals across admin IAM policy/user/service-account endpoints, STS
session policies, IAM imports/replication, bucket policies and bucket metadata
imports instead of silently accepting empty or wildcard fallbacks. Validate
the complete imported policy file before applying entries; retain supported
historical snapshot semantics and explicit null bucket-policy replication
tombstones. New session policies retain strict resource validation.
- Every explicit version removal in S3 `DeleteObjects`, including in buckets
without Object Lock, now uses the sequential single-object path to check
metadata, retention and replication under the mutation lock. Large version
purges can take longer. Unaddressed deletes that can replace a locked null
version also use the single-object path; other unaddressed deletes retain
bulk execution.
- Decommission rescans keys hidden below another key only while source
versions are actually removed, resolving the 20261109 limitation recorded
in [#293](https://github.com/pgsty/silo/issues/293). Decommission and rebalance
clean up successfully migrated sibling versions after concurrent deletion
of another version. Source-removal and final empty verification check every
online drive and tolerate offline drives within the erasure set's read quorum,
while rejecting online metadata remnants, corrupt metadata, directory errors
and drive outages beyond parity. Cancellation returns an error, and a scan
without physical progress stops. Free-version tier ownership and the final
physical-empty check remain protected
([#340](https://github.com/pgsty/silo/issues/340)). See the
[decommission procedure](https://silo.pgsty.com/operations/deployments/baremetal-decommission-server-pool/).
- Site-replication bucket healing, metadata healing, status collection and
replication hooks snapshot site topology and release the state read lock
before storage I/O and peer RPCs, avoiding deadlocks and state-map races
when a configuration writer is waiting
([#341](https://github.com/pgsty/silo/issues/341)). IAM import preserves
disabled service accounts and groups, reports group failures, and excludes
derived session-policy claims from exported and replicated credentials
([#345](https://github.com/pgsty/silo/issues/345)).
- Lifecycle history revision counting treats old and healed `xl.meta` headers
for the same version as one revision ([#332](https://github.com/pgsty/silo/issues/332)),
and restore expiry no longer turns a time-derived data-directory flag into a
different version revision; mixed healed headers can reach quorum and expired
local restore data can be cleaned up ([#344](https://github.com/pgsty/silo/issues/344)).
Historical-version self-copy plans its destination pool and data rewrite
before encryption or compression, preserving multi-block compression seek
indexes so a source version in another pool creates a readable new version.
In-place metadata updates locate the exact requested version and preserve
authoritative content on moving pools, while copies that require encryption
or checksum recomputation select an active pool
([#343](https://github.com/pgsty/silo/issues/343)).
- Batch KMS rotation decodes and validates the configured encryption context
(and incoming HTTP SSE-KMS context headers) as strict UTF-8 JSON before
changing an object key and without adding validation-probe fields to object
metadata, supports `SSE-S3` to `SSE-KMS` key migration and multipart metadata
preservation, and rewrites legacy `XLV1` objects onto `XLv2` streams instead
of failing in-place metadata updates
([#346](https://github.com/pgsty/silo/issues/346)). Batch variable
preflight follows the pinned policy evaluator's literal and substitution
rules, including BinaryEquals, while retaining refusal of unavailable
request context. This does not add AWS special-variable escape semantics
([#347](https://github.com/pgsty/silo/issues/347)).
- Service-account and access-key create/edit normalize a missing policy Version
to `2012-10-17`, reject malformed or unknown policy fields, and distinguish an
omitted or `null` policy (preserve existing inline policy) from an explicit
empty policy document such as `{}` or `{"Statement":[]}` (reset to parent
inheritance), while keeping historical deny-all accounts carrying an empty
embedded policy restricted until explicitly reset
([#339](https://github.com/pgsty/silo/issues/339),
[Console #89](https://github.com/pgsty/silo-console/issues/89),
[mcli #78](https://github.com/pgsty/mc/issues/78)). Previously,
`mcli admin user svcacct disable`, `enable` and `edit` without `--policy`,
the matching `admin accesskey` and `idp ldap/openid accesskey` commands, and
any other `update-service-account` request without `newPolicy` removed the
inline policy on the receiving site and on site-replication peers for
non-root parent users. In the matching Console, turn off **Restrict beyond
user policy** or enter `{}` to remove an inline policy; clearing the editor
while restriction is enabled prevents saving. Upgrade every site, then review
accounts that `svcacct info` reports with `Policy: implied` and re-apply
restrictions where needed.
- 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
@@ -19,6 +296,43 @@
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 ([PR #294](https://github.com/pgsty/silo/pull/294)).
## RELEASE.2026-10-26T00-00-00Z
+5 -2
View File
@@ -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
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -14,7 +14,7 @@ RUN mkdir -p /prep/data && chmod 0777 /prep/data
# Debian's bundle in distroless still trusts the roots the classic image
# blocklists. Build the same bundle as Dockerfile.goreleaser so both variants
# trust the same roots; it is architecture-neutral, so build it natively.
FROM --platform=$BUILDPLATFORM registry.access.redhat.com/ubi9/ubi:latest@sha256:16e9e43d99e181463513cc84a514859b1abb9460f1c4d9e208da4b4e51cd5cc3 AS certs
FROM --platform=$BUILDPLATFORM registry.access.redhat.com/ubi9/ubi:latest@sha256:8b212f75a0e5a156ac28b1c5c030d2e8a07715254e19d522286118279399af81 AS certs
# Native root blocklist mitigation: https://access.redhat.com/security/vulnerabilities/RHSB-2026-012
RUN dnf -y install ca-certificates && \
if [ -d /etc/pki/ca-trust/source/blocklist ]; then \
+7 -7
View File
@@ -1,4 +1,4 @@
FROM golang:1.27.3-alpine@sha256:d530cf60723abf1ffae0cd16bb546b379ec0550d2f3815e5b16ed69dbc00fba4 AS curl-build
FROM golang:1.27.4-alpine@sha256:0a5f7fe796599ff487605987432e7619ae45beeb2fba4c2ce0b907879932aebf AS curl-build
ARG TARGETARCH
COPY dockerscripts/build-static-curl.sh /build/build-static-curl
RUN /bin/sh /build/build-static-curl
@@ -9,7 +9,7 @@ COPY --from=curl-build /go/bin/curl /curl
COPY --from=curl-build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
ENTRYPOINT ["/curl"]
FROM golang:1.27.3-alpine@sha256:d530cf60723abf1ffae0cd16bb546b379ec0550d2f3815e5b16ed69dbc00fba4 AS build
FROM golang:1.27.4-alpine@sha256:0a5f7fe796599ff487605987432e7619ae45beeb2fba4c2ce0b907879932aebf AS build
ARG TARGETARCH
@@ -17,9 +17,9 @@ ENV GOPATH=/go
ENV CGO_ENABLED=0
ARG MC_REPO=pgsty/mc
ARG MC_VERSION=RELEASE.2026-11-09T00-00-00Z
ARG MC_AMD64_SHA256=79e6cf4ad00a9e5eb2e7ceccc6e6496e4af4ec096e59695515ea8f223c287e4a
ARG MC_ARM64_SHA256=5473c4b39a31dd66478f5c44686a09f3e9e37861a30f1faf18564fbe66118387
ARG MC_VERSION=RELEASE.2026-11-12T00-00-00Z
ARG MC_AMD64_SHA256=68875c7fdd06a76af12b74753506df65226d1bec65a405d2617d76448c229ba2
ARG MC_ARM64_SHA256=4ea59d2f278c96b25e380289fd2ee9bc49faa342687074f031805cb7b34fbe1a
RUN apk add -U --no-cache \
ca-certificates \
@@ -69,7 +69,7 @@ RUN apk add -U --no-cache \
mkdir -p /go/share/mcli && \
cp /tmp/mcli-extract/LICENSE /tmp/mcli-extract/NOTICE /tmp/mcli-extract/CREDITS /go/share/mcli/
FROM registry.access.redhat.com/ubi9/ubi:latest@sha256:16e9e43d99e181463513cc84a514859b1abb9460f1c4d9e208da4b4e51cd5cc3 AS certs
FROM registry.access.redhat.com/ubi9/ubi:latest@sha256:8b212f75a0e5a156ac28b1c5c030d2e8a07715254e19d522286118279399af81 AS certs
# Native root blocklist mitigation: https://access.redhat.com/security/vulnerabilities/RHSB-2026-012
RUN dnf -y install ca-certificates && \
if [ -d /etc/pki/ca-trust/source/blocklist ]; then \
@@ -88,7 +88,7 @@ RUN dnf -y install ca-certificates && \
dnf clean all && \
rm -rf /var/cache/dnf
FROM registry.access.redhat.com/ubi9/ubi-micro:latest@sha256:5be877a2a280ae36eb9ab80ca4efb28bbf77c57abbba38137cdc37a03b8465e6
FROM registry.access.redhat.com/ubi9/ubi-micro:latest@sha256:b59ac4f291d86027c2681f7a7b1cbf7448972fc55a6cb8e25cab5aa5eef5ce5f
LABEL org.opencontainers.image.title="Silo" \
org.opencontainers.image.description="S3-Interface Libre Object Storage" \
+2 -1
View File
@@ -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.
+13 -16
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+116
View File
@@ -0,0 +1,116 @@
#!/usr/bin/env bash
# Fail closed before a release job builds a commit that main never accepted or
# that CI never validated.
#
# check-release-state.sh answers "is this tag safe to write to". This answers
# the other half: "is this commit safe to ship". The release workflow already
# proves the tag resolves to the checkout, but a tag can be pushed to any
# commit at any time, including one that never landed on main and one whose
# tests have not finished.
set -euo pipefail
release_commit="${1:-}"
fixture="${2:-}"
repository="${GITHUB_REPOSITORY:-pgsty/silo}"
release_branch="${RELEASE_BRANCH:-origin/main}"
# Workflows that must have a successful run for this exact commit, keyed by the
# workflow file path rather than the display name: a name is free text, so a
# second workflow could otherwise be added that calls itself "Go CI", succeeds
# trivially, and satisfies this check while the real one fails.
required_workflow_paths=(
".github/workflows/go.yml"
".github/workflows/vulncheck.yml"
)
if ! command -v jq >/dev/null 2>&1; then
echo "jq is required to inspect workflow runs" >&2
exit 1
fi
if [[ ! "${release_commit}" =~ ^[0-9a-f]{40}$ ]]; then
echo "Invalid release commit: ${release_commit:-<empty>}" >&2
exit 1
fi
if [ -z "${fixture}" ]; then
if ! git rev-parse --verify --quiet "${release_branch}" >/dev/null; then
echo "Cannot resolve ${release_branch}; fetch it before releasing" >&2
exit 1
fi
# The exact tip, not merely an ancestor: an older commit on main has no
# push run of its own when it arrived as part of a multi-commit push, and a
# release must not trail behind what main already contains.
tip="$(git rev-parse "${release_branch}^{commit}")"
if [ "${tip}" != "${release_commit}" ]; then
echo "Refusing to release ${release_commit}: ${release_branch} is at ${tip}" >&2
exit 1
fi
echo "Release commit ${release_commit} is the tip of ${release_branch}."
error_file="$(mktemp)"
trap 'rm -f "${error_file}"' EXIT
# A pull_request run checks out GitHub's synthetic merge ref, so a green
# run proves the merge result was good, not that this commit was ever built
# on its own. exclude_pull_requests only trims the pull_requests field of
# each run; the event/head_branch filter below is what drops those runs.
if ! runs_json="$(
gh api --paginate \
"repos/${repository}/actions/runs?head_sha=${release_commit}&exclude_pull_requests=true&per_page=100" \
--jq '.workflow_runs[] | {path, event, head_branch, head_sha, status, conclusion, run_number, run_attempt}' \
2>"${error_file}" | jq -s '.'
)"; then
cat "${error_file}" >&2
exit 1
fi
else
runs_json="$(<"${fixture}")"
fi
if ! jq -e 'type == "array" and all(.[]; type == "object" and (.path | type == "string"))' \
<<<"${runs_json}" >/dev/null 2>&1; then
echo "Invalid workflow run response for ${release_commit}" >&2
exit 1
fi
fail=0
for workflow in "${required_workflow_paths[@]}"; do
# Only runs that actually built this commit count: the right workflow file,
# triggered by the push that put this SHA on main. A manual dispatch is not
# substitute evidence - it can be run from any ref. Take the newest such
# run; an older success must not paper over a later failure.
latest="$(jq -c --arg path "${workflow}" --arg sha "${release_commit}" '
[ .[]
| select(.path == $path)
| select(.head_sha == $sha)
| select(.event == "push" and .head_branch == "main")
]
| sort_by([.run_number // 0, .run_attempt // 0])
| last // null' <<<"${runs_json}")"
if [ "${latest}" = "null" ]; then
echo "Required workflow ${workflow} has no qualifying run for ${release_commit} (no run)" >&2
fail=1
continue
fi
status="$(jq -r '.status // "unknown"' <<<"${latest}")"
conclusion="$(jq -r '.conclusion // "none"' <<<"${latest}")"
if [ "${status}" != "completed" ] || [ "${conclusion}" != "success" ]; then
observed="${conclusion}"
if [ "${status}" != "completed" ]; then
observed="${status}"
fi
echo "Required workflow ${workflow} did not succeed for ${release_commit} (${observed})" >&2
fail=1
fi
done
if [ "${fail}" -ne 0 ]; then
echo "Refusing to release ${release_commit}: required checks are not green" >&2
exit 1
fi
echo "Required workflows are green for ${release_commit}."
+132
View File
@@ -0,0 +1,132 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
checker="${script_dir}/check-release-commit.sh"
commit="0123456789abcdef0123456789abcdef01234567"
other_commit="89abcdef0123456789abcdef0123456789abcdef"
fixture="$(mktemp)"
stdout_file="$(mktemp)"
stderr_file="$(mktemp)"
trap 'rm -f "${fixture}" "${stdout_file}" "${stderr_file}"' EXIT
expect_success() {
if ! "${checker}" "$@" >"${stdout_file}" 2>"${stderr_file}"; then
cat "${stderr_file}" >&2
return 1
fi
}
expect_failure() {
if "${checker}" "$@" >"${stdout_file}" 2>"${stderr_file}"; then
echo "Expected release-commit check to fail: $*" >&2
cat "${stdout_file}" >&2
return 1
fi
}
# run <path> <conclusion> [event] [branch] [sha] [run_number] [status]
run() {
local path="$1" conclusion="$2"
local event="${3:-push}" branch="${4:-main}" sha="${5:-${commit}}"
local number="${6:-1}" status="${7:-completed}"
local conclusion_json="\"${conclusion}\""
[ "${conclusion}" = "null" ] && conclusion_json=null
printf '{"path":"%s","event":"%s","head_branch":"%s","head_sha":"%s","status":"%s","conclusion":%s,"run_number":%s,"run_attempt":1}' \
"${path}" "${event}" "${branch}" "${sha}" "${status}" "${conclusion_json}" "${number}"
}
go_ok="$(run .github/workflows/go.yml success)"
vuln_ok="$(run .github/workflows/vulncheck.yml success)"
all_green() {
printf '[%s,%s]\n' "${go_ok}" "${vuln_ok}" >"${fixture}"
}
# Baseline: both required workflows green on a push to main.
all_green
expect_success "${commit}" "${fixture}"
grep -qF "Required workflows are green for ${commit}." "${stdout_file}"
# A manual dispatch is not evidence: it can be run from any ref.
printf '[%s,%s]\n' \
"$(run .github/workflows/go.yml success workflow_dispatch main)" \
"${vuln_ok}" >"${fixture}"
expect_failure "${commit}" "${fixture}"
grep -qF "no qualifying run" "${stderr_file}"
# A re-run that finally succeeded is enough; the earlier failure is history.
printf '[%s,%s,%s]\n' \
"$(run .github/workflows/go.yml failure push main "${commit}" 1)" \
"$(run .github/workflows/go.yml success push main "${commit}" 2)" \
"${vuln_ok}" >"${fixture}"
expect_success "${commit}" "${fixture}"
# ...but a LATER failure must not be papered over by an earlier success.
printf '[%s,%s,%s]\n' \
"$(run .github/workflows/go.yml success push main "${commit}" 1)" \
"$(run .github/workflows/go.yml failure push main "${commit}" 2)" \
"${vuln_ok}" >"${fixture}"
expect_failure "${commit}" "${fixture}"
grep -qF "Required workflow .github/workflows/go.yml did not succeed" "${stderr_file}"
grep -qF "(failure)" "${stderr_file}"
# A decoy workflow that merely calls itself "Go CI" must not satisfy the gate.
printf '[%s,%s]\n' \
"$(run .github/workflows/decoy.yml success)" "${vuln_ok}" >"${fixture}"
expect_failure "${commit}" "${fixture}"
grep -qF "Required workflow .github/workflows/go.yml has no qualifying run" "${stderr_file}"
# A pull_request run tests GitHub's merge ref, not this commit on its own.
printf '[%s,%s]\n' \
"$(run .github/workflows/go.yml success pull_request feature)" "${vuln_ok}" >"${fixture}"
expect_failure "${commit}" "${fixture}"
grep -qF "no qualifying run" "${stderr_file}"
# A push run on some other branch is not evidence that main accepted it.
printf '[%s,%s]\n' \
"$(run .github/workflows/go.yml success push feature)" "${vuln_ok}" >"${fixture}"
expect_failure "${commit}" "${fixture}"
grep -qF "no qualifying run" "${stderr_file}"
# A run reporting a different head_sha must be ignored.
printf '[%s,%s]\n' \
"$(run .github/workflows/go.yml success push main "${other_commit}")" "${vuln_ok}" >"${fixture}"
expect_failure "${commit}" "${fixture}"
grep -qF "no qualifying run" "${stderr_file}"
# Missing workflow entirely.
printf '[%s]\n' "${go_ok}" >"${fixture}"
expect_failure "${commit}" "${fixture}"
grep -qF "Required workflow .github/workflows/vulncheck.yml has no qualifying run" "${stderr_file}"
# Still running.
printf '[%s,%s]\n' "${go_ok}" \
"$(run .github/workflows/vulncheck.yml null push main "${commit}" 1 in_progress)" >"${fixture}"
expect_failure "${commit}" "${fixture}"
grep -qF "(in_progress)" "${stderr_file}"
# Cancelled counts as not green.
printf '[%s,%s]\n' "${go_ok}" \
"$(run .github/workflows/vulncheck.yml cancelled)" >"${fixture}"
expect_failure "${commit}" "${fixture}"
grep -qF "(cancelled)" "${stderr_file}"
# Malformed API response.
printf '{not-json}\n' >"${fixture}"
expect_failure "${commit}" "${fixture}"
grep -qF "Invalid workflow run response for ${commit}" "${stderr_file}"
# An empty array is a valid response that satisfies nothing.
printf '[]\n' >"${fixture}"
expect_failure "${commit}" "${fixture}"
grep -qF "Required workflow .github/workflows/go.yml has no qualifying run" "${stderr_file}"
expect_failure "not-a-commit" "${fixture}"
grep -qF "Invalid release commit" "${stderr_file}"
expect_failure "" "${fixture}"
grep -qF "Invalid release commit: <empty>" "${stderr_file}"
echo "release-commit decision tests passed"
+1 -1
View File
@@ -7,7 +7,7 @@ _init() {
## Minimum required versions for build dependencies
GIT_VERSION="1.0"
GO_VERSION="1.27.3"
GO_VERSION="1.27.4"
OSX_VERSION="10.8"
KNAME=$(uname -s)
ARCH=$(uname -m)
+1 -1
View File
@@ -40,7 +40,7 @@ if [ -n "${MCLI_BIN:-}" ]; then
exit 0
fi
release=${MCLI_RELEASE:-RELEASE.2026-11-09T00-00-00Z}
release=${MCLI_RELEASE:-RELEASE.2026-11-12T00-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
@@ -0,0 +1,156 @@
// Copyright (c) 2026 PGSTY
// SPDX-License-Identifier: AGPL-3.0-or-later
// release-dependency-guard checks the modules and bundled client actually used
// by a release. A local workspace must not substitute unreleased source trees.
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"golang.org/x/mod/semver"
)
type module struct {
Path, Version, Dir string
Main bool
Replace *module
}
func selected(m module, path string) (module, error) {
if m.Replace != nil {
m = *m.Replace
}
if m.Main || m.Path != path || m.Version == "" {
return module{}, fmt.Errorf("%s must select an immutable maintained module, got %s@%s", path, m.Path, m.Version)
}
return m, nil
}
func atLeast(version, minimum string) bool {
return semver.IsValid(version) && semver.Prerelease(version) == "" && semver.Compare(version, minimum) >= 0
}
func validate(modules map[string]module, consoleVersion, mcTag, mcCommit string) error {
pkg, err := selected(modules["github.com/pgsty/silo-pkg/v3"], "github.com/pgsty/silo-pkg/v3")
if err != nil {
return err
}
if !atLeast(pkg.Version, "v3.16.1") {
return fmt.Errorf("silo-pkg %s lacks the strict policy release; need v3.16.1 or newer", pkg.Version)
}
if _, err := selected(modules["github.com/minio/console"], "github.com/pgsty/silo-console"); err != nil {
return err
}
if !atLeast("v"+consoleVersion, "v2.7.1") {
return fmt.Errorf("embedded Console %s lacks the policy fixes; need 2.7.1 or newer", consoleVersion)
}
mc, err := selected(modules["github.com/minio/mc"], "github.com/pgsty/mc")
if err != nil {
return err
}
if !regexp.MustCompile(`^RELEASE\.\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z$`).MatchString(mcTag) || mcTag <= "RELEASE.2026-11-09T00-00-00Z" {
return fmt.Errorf("bundled mcli %s must be a release after 20261109", mcTag)
}
if len(mcCommit) != 40 || !strings.HasSuffix(mc.Version, "-"+mcCommit[:12]) {
return fmt.Errorf("mc module %s does not match Dockerfile.goreleaser tag %s (%s)", mc.Version, mcTag, mcCommit)
}
return nil
}
func command(name string, args ...string) ([]byte, error) {
cmd := exec.Command(name, args...)
cmd.Stderr = os.Stderr
return cmd.Output()
}
func check() error {
if err := os.Setenv("GOWORK", "off"); err != nil {
return err
}
data, err := command("go", "list", "-mod=readonly", "-m", "-json", "github.com/pgsty/silo-pkg/v3", "github.com/minio/console", "github.com/minio/mc")
if err != nil {
return err
}
modules := make(map[string]module)
dec := json.NewDecoder(bytes.NewReader(data))
for {
var m module
if err := dec.Decode(&m); err != nil {
if err == io.EOF {
break
}
return err
}
modules[m.Path] = m
}
pkg, err := selected(modules["github.com/pgsty/silo-pkg/v3"], "github.com/pgsty/silo-pkg/v3")
if err != nil {
return err
}
if !atLeast(pkg.Version, "v3.16.1") {
return fmt.Errorf("release blocked: select silo-pkg v3.16.1 or newer (currently %s)", pkg.Version)
}
console, err := selected(modules["github.com/minio/console"], "github.com/pgsty/silo-console")
if err != nil {
return err
}
if console.Dir == "" {
data, err = command("go", "mod", "download", "-json", console.Path+"@"+console.Version)
if err != nil {
return err
}
if err := json.Unmarshal(data, &console); err != nil {
return err
}
}
data, err = os.ReadFile(filepath.Join(console.Dir, "web-app", "package.json"))
if err != nil {
return err
}
var metadata struct{ Version string }
if err := json.Unmarshal(data, &metadata); err != nil {
return err
}
data, err = os.ReadFile("Dockerfile.goreleaser")
if err != nil {
return err
}
match := regexp.MustCompile(`(?m)^ARG MC_VERSION=(\S+)\s*$`).FindSubmatch(data)
if len(match) != 2 {
return fmt.Errorf("Dockerfile.goreleaser must pin MC_VERSION")
}
tag := string(match[1])
data, err = command("gh", "api", "repos/pgsty/mc/commits/"+tag, "--jq", ".sha")
if err != nil {
return err
}
commit := strings.TrimSpace(string(data))
if err := validate(modules, metadata.Version, tag, commit); err != nil {
return err
}
data, err = command("gh", "api", "repos/pgsty/mc/compare/d7262c2a5fbdf2b9503cc0283f1d37ae8da41baf..."+commit, "--jq", ".status")
if err != nil {
return err
}
if status := strings.TrimSpace(string(data)); status != "ahead" && status != "identical" {
return fmt.Errorf("mcli %s does not include the access-key policy repair", tag)
}
fmt.Printf("Release dependencies verified: silo-pkg %s, Console %s, mcli %s (%s)\n", pkg.Version, metadata.Version, tag, commit)
return nil
}
func main() {
if err := check(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
@@ -0,0 +1,44 @@
// Copyright (c) 2026 PGSTY
// SPDX-License-Identifier: AGPL-3.0-or-later
package main
import "testing"
func TestReleaseDependencySelection(t *testing.T) {
const commit = "0123456789abcdef0123456789abcdef01234567"
for _, tc := range []struct {
name, pkg, console, tag, mcSHA string
local, upstream, wantError bool
}{
{name: "coordinated", pkg: "v3.16.1", console: "2.7.1", tag: "RELEASE.2026-11-12T00-00-00Z", mcSHA: commit},
{name: "old-package", pkg: "v3.16.0", console: "2.7.1", tag: "RELEASE.2026-11-12T00-00-00Z", mcSHA: commit, wantError: true},
{name: "prerelease-package", pkg: "v3.16.1-rc.1", console: "2.7.1", tag: "RELEASE.2026-11-12T00-00-00Z", mcSHA: commit, wantError: true},
{name: "old-embedded-console", pkg: "v3.16.1", console: "2.7.0", tag: "RELEASE.2026-11-12T00-00-00Z", mcSHA: commit, wantError: true},
{name: "old-container-client", pkg: "v3.16.1", console: "2.7.1", tag: "RELEASE.2026-11-09T00-00-00Z", mcSHA: commit, wantError: true},
{name: "source-binary-mismatch", pkg: "v3.16.1", console: "2.7.1", tag: "RELEASE.2026-11-12T00-00-00Z", mcSHA: "ffffffffffffffffffffffffffffffffffffffff", wantError: true},
{name: "workspace-console", pkg: "v3.16.1", console: "2.7.1", tag: "RELEASE.2026-11-12T00-00-00Z", mcSHA: commit, local: true, wantError: true},
{name: "upstream-console", pkg: "v3.16.1", console: "2.7.1", tag: "RELEASE.2026-11-12T00-00-00Z", mcSHA: commit, upstream: true, wantError: true},
} {
t.Run(tc.name, func(t *testing.T) {
modules := map[string]module{
"github.com/pgsty/silo-pkg/v3": {Path: "github.com/pgsty/silo-pkg/v3", Version: tc.pkg},
"github.com/minio/console": {Path: "github.com/minio/console", Replace: &module{
Path: "github.com/pgsty/silo-console", Version: "v0.0.0-20261112000000-0123456789ab",
}},
"github.com/minio/mc": {Path: "github.com/minio/mc", Replace: &module{
Path: "github.com/pgsty/mc", Version: "v0.0.0-20261112000000-0123456789ab",
}},
}
if tc.local {
modules["github.com/minio/console"].Replace.Version = ""
}
if tc.upstream {
modules["github.com/minio/console"].Replace.Path = "github.com/minio/console"
}
if err := validate(modules, tc.console, tc.tag, tc.mcSHA); (err != nil) != tc.wantError {
t.Fatalf("got %v, wantError=%t", err, tc.wantError)
}
})
}
}
+10 -1
View File
@@ -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
+7 -7
View File
@@ -817,17 +817,17 @@ func (a adminAPIHandlers) ImportBucketMetadataHandler(w http.ResponseWriter, r *
bucketMap[bucket] = &v
}
if globalSiteReplicationSys.isEnabled() && v.Suspended() {
rpt.SetStatus(bucket, fileName, fmt.Errorf("Cluster replication is enabled for this site, so the versioning state cannot be suspended."))
if globalSiteReplicationSys.isEnabled() && (v.Suspended() || v.PrefixesExcluded()) {
rpt.SetStatus(bucket, fileName, fmt.Errorf("Cluster replication is enabled for this site, so the versioning state cannot be suspended or exclude prefixes."))
continue
}
if rcfg, _ := globalBucketObjectLockSys.Get(bucket); rcfg.LockEnabled && v.Suspended() {
rpt.SetStatus(bucket, fileName, fmt.Errorf("An Object Lock configuration is present on this bucket, so the versioning state cannot be suspended."))
if rcfg, _ := globalBucketObjectLockSys.Get(bucket); rcfg.LockEnabled && (v.Suspended() || v.PrefixesExcluded()) {
rpt.SetStatus(bucket, fileName, fmt.Errorf("An Object Lock configuration is present on this bucket, so the versioning state cannot be suspended or exclude prefixes."))
continue
}
if rcfg, _ := getReplicationConfig(ctx, bucket); rcfg != nil && v.Suspended() {
rpt.SetStatus(bucket, fileName, fmt.Errorf("A replication configuration is present on this bucket, so the versioning state cannot be suspended."))
if rcfg, _ := getReplicationConfig(ctx, bucket); rcfg != nil && (v.Suspended() || v.PrefixesExcluded()) {
rpt.SetStatus(bucket, fileName, fmt.Errorf("A replication configuration is present on this bucket, so the versioning state cannot be suspended or exclude prefixes."))
continue
}
@@ -907,7 +907,7 @@ func (a adminAPIHandlers) ImportBucketMetadataHandler(w http.ResponseWriter, r *
continue
}
bucketPolicy, err := policy.ParseBucketPolicyConfig(bytes.NewReader(bucketPolicyBytes), bucket)
bucketPolicy, err := parseBucketPolicyStrictBytes(bucketPolicyBytes, bucket)
if err != nil {
rpt.SetStatus(bucket, fileName, err)
continue
+4 -4
View File
@@ -165,10 +165,10 @@ func (a adminAPIHandlers) SRPeerReplicateIAMItem(w http.ResponseWriter, r *http.
default:
err = errSRInvalidRequest(errInvalidArgument)
case madmin.SRIAMItemPolicy:
if item.Policy == nil {
if item.Policy == nil || bytes.Equal(bytes.TrimSpace(item.Policy), []byte("null")) {
err = globalSiteReplicationSys.PeerAddPolicyHandler(ctx, item.Name, nil, item.UpdatedAt)
} else {
policy, perr := policy.ParseConfig(bytes.NewReader(item.Policy))
policy, perr := parsePolicySnapshotBytes(item.Policy)
if perr != nil {
writeErrorResponseJSON(ctx, w, toAdminAPIErr(ctx, perr), r.URL)
return
@@ -222,10 +222,10 @@ func (a adminAPIHandlers) SRPeerReplicateBucketItem(w http.ResponseWriter, r *ht
default:
err = globalSiteReplicationSys.PeerBucketMetadataUpdateHandler(ctx, item)
case madmin.SRBucketMetaTypePolicy:
if item.Policy == nil {
if item.Policy == nil || bytes.Equal(bytes.TrimSpace(item.Policy), []byte("null")) {
err = globalSiteReplicationSys.PeerBucketPolicyHandler(ctx, item.Bucket, nil, item.UpdatedAt)
} else {
bktPolicy, berr := policy.ParseBucketPolicyConfig(bytes.NewReader(item.Policy), item.Bucket)
bktPolicy, berr := parseBucketPolicyStrictBytes(item.Policy, item.Bucket)
if berr != nil {
writeErrorResponseJSON(ctx, w, toAdminAPIErr(ctx, berr), r.URL)
return
+3 -2
View File
@@ -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}
+66 -66
View File
@@ -885,16 +885,17 @@ 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 {
sp, err = policy.ParseConfigStrict(bytes.NewReader(updateReq.NewPolicy))
trimmedPolicy := bytes.TrimSpace(updateReq.NewPolicy)
if len(trimmedPolicy) > 0 && !bytes.Equal(trimmedPolicy, []byte("null")) {
sp, err = parseSessionPolicyStrictBytes(trimmedPolicy)
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 +904,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,21 +915,13 @@ func (a adminAPIHandlers) UpdateServiceAccount(w http.ResponseWriter, r *http.Re
}
// Call site replication hook - non-root user accounts are replicated.
// The hook sends the account as it is stored after this edit, not the
// fields the request changed (see replicationItem).
if svcAccount.ParentUser != globalActiveCred.AccessKey {
replLogIf(ctx, globalSiteReplicationSys.IAMChangeHook(ctx, madmin.SRIAMItem{
Type: madmin.SRIAMItemSvcAcc,
SvcAccChange: &madmin.SRSvcAccChange{
Update: &madmin.SRSvcAccUpdate{
AccessKey: accessKey,
SecretKey: opts.secretKey,
Status: opts.status,
Name: opts.name,
Description: opts.description,
SessionPolicy: madmin.SRSessionPolicy(updateReq.NewPolicy),
Expiration: updateReq.NewExpiration,
},
},
UpdatedAt: updatedAt,
Type: madmin.SRIAMItemSvcAcc,
SvcAccChange: &madmin.SRSvcAccChange{Update: &madmin.SRSvcAccUpdate{AccessKey: accessKey}},
UpdatedAt: updatedAt,
}))
}
@@ -1771,7 +1767,7 @@ func (a adminAPIHandlers) AddCannedPolicy(w http.ResponseWriter, r *http.Request
return
}
iamPolicy, err := policy.ParseConfigStrict(bytes.NewReader(iamPolicyBytes))
iamPolicy, err := parsePolicyStrictBytes(iamPolicyBytes)
if err != nil {
writeErrorResponseJSON(ctx, w, toAdminAPIErr(ctx, err), r.URL)
return
@@ -2396,37 +2392,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)
@@ -2539,23 +2511,34 @@ func (a adminAPIHandlers) importIAM(w http.ResponseWriter, r *http.Request, apiV
return
default:
defer f.Close()
var allPolicies map[string]policy.Policy
var policyDocuments map[string]json.RawMessage
data, err = io.ReadAll(f)
if err != nil {
writeErrorResponseJSON(ctx, w, importErrorWithAPIErr(ctx, ErrInvalidRequest, err, allPoliciesFile, ""), r.URL)
return
}
err = json.Unmarshal(data, &allPolicies)
err = json.Unmarshal(data, &policyDocuments)
if err != nil {
writeErrorResponseJSON(ctx, w, importErrorWithAPIErr(ctx, ErrAdminConfigBadJSON, err, allPoliciesFile, ""), r.URL)
return
}
// Validate the entire policy file before applying any entries. Keep
// legacy semantic validation, but never discard unknown wire fields.
allPolicies := make(map[string]*policy.Policy, len(policyDocuments))
for policyName, document := range policyDocuments {
parsed, parseErr := parsePolicySnapshotBytes(document)
if parseErr != nil {
writeErrorResponseJSON(ctx, w, importErrorWithAPIErr(ctx, ErrAdminConfigBadJSON, parseErr, allPoliciesFile, policyName), r.URL)
return
}
allPolicies[policyName] = parsed
}
for policyName, policy := range allPolicies {
if policy.IsEmpty() {
err = globalIAMSys.DeletePolicy(ctx, policyName, true)
removed.Policies = append(removed.Policies, policyName)
} else {
_, err = globalIAMSys.SetPolicy(ctx, policyName, policy)
_, err = globalIAMSys.SetPolicy(ctx, policyName, *policy)
added.Policies = append(added.Policies, policyName)
}
if err != nil {
@@ -2647,8 +2630,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)
}
@@ -2694,10 +2686,13 @@ func (a adminAPIHandlers) importIAM(w http.ResponseWriter, r *http.Request, apiV
var sp *policy.Policy
var err error
if len(svcAcctReq.SessionPolicy) > 0 {
sp, err = policy.ParseConfig(bytes.NewReader(svcAcctReq.SessionPolicy))
if err != nil {
writeErrorResponseJSON(ctx, w, importError(ctx, err, allSvcAcctsFile, user), r.URL)
return
trimmedSP := bytes.TrimSpace(svcAcctReq.SessionPolicy)
if len(trimmedSP) > 0 && !bytes.Equal(trimmedSP, []byte("null")) {
sp, err = parsePolicySnapshotBytes(trimmedSP)
if err != nil {
writeErrorResponseJSON(ctx, w, importError(ctx, err, allSvcAcctsFile, user), r.URL)
return
}
}
}
// service account access key cannot have space characters
@@ -2726,14 +2721,16 @@ func (a adminAPIHandlers) importIAM(w http.ResponseWriter, r *http.Request, apiV
}
}
opts := newServiceAccountOpts{
accessKey: user,
secretKey: svcAcctReq.SecretKey,
sessionPolicy: sp,
claims: svcAcctReq.Claims,
name: svcAcctReq.Name,
description: svcAcctReq.Description,
expiration: svcAcctReq.Expiration,
allowSiteReplicatorAccount: false,
accessKey: user,
secretKey: svcAcctReq.SecretKey,
status: svcAcctReq.Status,
sessionPolicy: sp,
claims: svcAcctReq.Claims,
name: svcAcctReq.Name,
description: svcAcctReq.Description,
expiration: svcAcctReq.Expiration,
allowSiteReplicatorAccount: false,
preserveEmptyEmbeddedPolicy: svcAcctClaimsHaveEmbeddedPolicy(svcAcctReq.Claims),
}
if _, _, err = globalIAMSys.NewServiceAccount(ctx, svcAcctReq.Parent, svcAcctReq.Groups, opts); err != nil {
@@ -3077,14 +3074,17 @@ func commonAddServiceAccount(r *http.Request, ldap bool) (context.Context, auth.
}
var sp *policy.Policy
if len(createReq.Policy) > 0 {
sp, err = policy.ParseConfigStrict(bytes.NewReader(createReq.Policy))
trimmedCreatePolicy := bytes.TrimSpace(createReq.Policy)
if bytes.Equal(trimmedCreatePolicy, []byte("null")) {
createReq.Policy = nil
} else if len(trimmedCreatePolicy) > 0 {
sp, err = parseSessionPolicyStrictBytes(trimmedCreatePolicy)
if err != nil {
return ctx, auth.Credentials{}, newServiceAccountOpts{}, madmin.AddServiceAccountReq{}, "", toAdminAPIErr(ctx, err)
}
// 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
}
+4 -3
View File
@@ -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 {
+6 -9
View File
@@ -568,18 +568,15 @@ func generateListVersionsResponse(ctx context.Context, bucket, prefix, marker, v
DisplayName: "minio",
}
data := ListVersionsResponse{}
var lastObjMetaName string
var tagErr, metaErr APIErrorCode = -1, -1
for _, object := range resp.Objects {
if object.Name == "" {
continue
}
// Cache checks for the same object
if metadata != nil && lastObjMetaName != object.Name {
tagErr = metadata(object.Name, policy.GetObjectTaggingAction)
metaErr = metadata(object.Name, policy.GetObjectAction)
lastObjMetaName = object.Name
if metadata != nil {
tagErr = metadata(object, policy.GetObjectTaggingAction)
metaErr = metadata(object, policy.GetObjectAction)
}
content := ObjectVersion{}
content.Key = s3EncodeName(object.Name, encodingType)
@@ -727,10 +724,10 @@ func generateListObjectsV2Response(ctx context.Context, bucket, prefix, token, n
}
content.Owner = owner
if metadata != nil {
if metadata(object.Name, policy.GetObjectTaggingAction) == ErrNone {
if metadata(object, policy.GetObjectTaggingAction) == ErrNone {
content.UserTags = object.UserTags
}
if metadata(object.Name, policy.GetObjectAction) == ErrNone {
if metadata(object, policy.GetObjectAction) == ErrNone {
content.UserMetadata = &Metadata{}
switch kind, _ := crypto.IsEncrypted(object.UserDefined); kind {
case crypto.S3:
@@ -774,7 +771,7 @@ func generateListObjectsV2Response(ctx context.Context, bucket, prefix, token, n
return data
}
type metaCheckFn = func(name string, action policy.Action) (s3Err APIErrorCode)
type metaCheckFn = func(object ObjectInfo, action policy.Action) (s3Err APIErrorCode)
// generates CopyObjectResponse from the committed object information.
func generateCopyObjectResponse(oi ObjectInfo, cs map[string]string) CopyObjectResponse {
+716
View File
@@ -0,0 +1,716 @@
// 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"
)
func withScopedReqObject(ctx context.Context, bucket, object, versionID string, fn func() APIErrorCode) APIErrorCode {
reqInfo := logger.GetReqInfo(ctx)
if reqInfo == nil {
return ErrAccessDenied
}
prevBucket, prevObject, prevVID := reqInfo.BucketName, reqInfo.ObjectName, reqInfo.VersionID
reqInfo.BucketName = bucket
reqInfo.ObjectName = object
reqInfo.VersionID = versionID
defer func() {
reqInfo.BucketName = prevBucket
reqInfo.ObjectName = prevObject
reqInfo.VersionID = prevVID
}()
return fn()
}
func objectVersionAction(base policy.Action, versionID string) (policy.Action, bool) {
if versionID == "" {
return base, false
}
switch base {
case policy.GetObjectAction:
return policy.GetObjectVersionAction, true
case policy.GetObjectTaggingAction:
return policy.GetObjectVersionTaggingAction, true
case policy.PutObjectTaggingAction:
return policy.PutObjectVersionTaggingAction, true
case policy.DeleteObjectTaggingAction:
return policy.DeleteObjectVersionTaggingAction, true
default:
return base, false
}
}
func (sys *IAMSys) policySetsForExplicitDeny(cred auth.Credentials) [][]policy.Policy {
derived := cred.IsTemp() || cred.IsServiceAccount()
name := cred.AccessKey
if derived {
name = cred.ParentUser
}
cache := sys.store.rlock()
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 {
sys.store.runlock()
return nil
}
names = newMappedPolicy(sys.rolesMap[parsed]).toSlice()
default:
var err error
names, _, err = cache.policyDBGet(sys.store, name, false, false)
if err != nil && err != errNoSuchPolicy {
sys.store.runlock()
return nil
}
groups, gerr := cache.policyDBGetGroups(sys.store, len(names) > 0, cred.Groups...)
if gerr != nil && gerr != errNoSuchPolicy {
sys.store.runlock()
return nil
}
names = append(names, groups...)
if derived && len(names) == 0 {
claims, _ := policy.GetPoliciesFromClaims(cred.Claims, iamPolicyClaimNameOpenID())
names = claims.ToSlice()
}
}
sys.store.runlock()
sets := [][]policy.Policy{nil}
slices.Sort(names)
compactNames := slices.Compact(names)
if len(compactNames) > 0 {
if avail, merged, err := sys.store.MergePolicies(strings.Join(compactNames, ",")); err == nil && avail != "" {
sets[0] = append(sets[0], merged)
}
}
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
}
func isActionExplicitlyDenied(ctx context.Context, r *http.Request, action policy.Action, existingTags string, requestTags *string) bool {
reqInfo := logger.GetReqInfo(ctx)
if reqInfo == nil {
return true
}
if reqInfo.Owner {
return false
}
cred := reqInfo.Cred
bucket := reqInfo.BucketName
object := reqInfo.ObjectName
versionID := reqInfo.VersionID
buildValues := func(loc string, c auth.Credentials) map[string][]string {
values := getConditionValuesWithTags(r, loc, c, existingTags, requestTags)
if actionUsesObjectVersion(action) {
if versionID == "" {
delete(values, "versionid")
} else {
values["versionid"] = []string{versionID}
}
}
return values
}
if cred.AccessKey == "" {
p, err := globalPolicySys.Get(bucket)
if err != nil {
return false
}
bArgs := policy.BucketPolicyArgs{
Action: action,
BucketName: bucket,
ObjectName: object,
ConditionValues: buildValues(reqInfo.Region, auth.AnonymousCredentials),
IsOwner: false,
}
for _, st := range p.Statements {
if st.Effect != policy.Deny {
continue
}
probe := st
probe.Effect = policy.Allow
if probe.IsAllowed(bArgs) {
return true
}
}
return false
}
values := buildValues("", cred)
if newGlobalAuthZPluginFn() != nil {
return !globalIAMSys.IsAllowed(policy.Args{
AccountName: cred.AccessKey,
Groups: cred.Groups,
Action: action,
BucketName: bucket,
ObjectName: object,
ConditionValues: values,
IsOwner: false,
Claims: cred.Claims,
DenyOnly: true,
})
}
if !globalIAMSys.Initialized() {
return false
}
args := policy.Args{
AccountName: cred.AccessKey,
Groups: cred.Groups,
Action: action,
BucketName: bucket,
ObjectName: object,
ConditionValues: values,
IsOwner: false,
Claims: cred.Claims,
DenyOnly: true,
}
for _, policies := range globalIAMSys.policySetsForExplicitDeny(cred) {
if len(policies) == 0 {
continue
}
merged := policy.MergePolicies(policies...)
if !merged.IsAllowed(args) {
return true
}
}
return false
}
func authorizeVersionedRequestWithTags(ctx context.Context, r *http.Request, baseAction policy.Action, versionID, existingTags string, requestTags *string) APIErrorCode {
reqInfo := logger.GetReqInfo(ctx)
if reqInfo == nil {
return ErrAccessDenied
}
prevVID := reqInfo.VersionID
reqInfo.VersionID = versionID
defer func() { reqInfo.VersionID = prevVID }()
if verAction, ok := objectVersionAction(baseAction, versionID); ok {
if authorizeRequestWithTags(ctx, r, verAction, existingTags, requestTags) == ErrNone {
return ErrNone
}
if isActionExplicitlyDenied(ctx, r, verAction, existingTags, requestTags) {
return ErrAccessDenied
}
}
return authorizeRequestWithTags(ctx, r, baseAction, existingTags, requestTags)
}
func authorizeVersionedRequestWithExistingTags(ctx context.Context, r *http.Request, baseAction policy.Action, versionID, existingTags string) APIErrorCode {
return authorizeVersionedRequestWithTags(ctx, r, baseAction, versionID, existingTags, nil)
}
func authorizeVersionedRequestWithUnreadTags(ctx context.Context, r *http.Request, baseAction policy.Action, versionID string, readErr error) APIErrorCode {
s3Err := authorizeVersionedRequestWithExistingTags(ctx, r, baseAction, versionID, "")
if s3Err == ErrNone || toAPIError(ctx, readErr).HTTPStatusCode < http.StatusInternalServerError {
return s3Err
}
return authorizeVersionedRequestWithTagCandidates(ctx, r, baseAction, versionID, s3Err)
}
func authorizeVersionedRequestWithPotentialTags(ctx context.Context, r *http.Request, baseAction policy.Action, versionID string) APIErrorCode {
s3Err := authorizeVersionedRequestWithExistingTags(ctx, r, baseAction, versionID, "")
if s3Err == ErrNone {
return ErrNone
}
return authorizeVersionedRequestWithTagCandidates(ctx, r, baseAction, versionID, s3Err)
}
func authorizeVersionedRequestWithTagCandidates(ctx context.Context, r *http.Request, baseAction policy.Action, versionID string, s3Err APIErrorCode) APIErrorCode {
reqInfo := logger.GetReqInfo(ctx)
if reqInfo == nil {
return ErrAccessDenied
}
prevVID := reqInfo.VersionID
reqInfo.VersionID = versionID
defer func() { reqInfo.VersionID = prevVID }()
candidates := policyExistingObjectTags(ctx, r, baseAction)
if verAction, ok := objectVersionAction(baseAction, versionID); ok {
candidates = append(candidates, policyExistingObjectTags(ctx, r, verAction)...)
}
for _, existingTags := range slices.Compact(candidates) {
if authorizeVersionedRequestWithExistingTags(ctx, r, baseAction, versionID, existingTags) == ErrNone {
return ErrNone
}
}
return s3Err
}
// canPotentiallyAllowAction reports whether the authenticated caller has any
// grant capable of allowing action on bucket/object once condition values
// (such as copied source tags on CopyObject) are resolved.
func canPotentiallyAllowAction(ctx context.Context, r *http.Request, action policy.Action, bucket, object string) bool {
reqInfo := logger.GetReqInfo(ctx)
if reqInfo == nil {
return false
}
emptyTags := ""
if withScopedReqObject(ctx, bucket, object, "", func() APIErrorCode {
return authorizeRequestWithTags(ctx, r, action, "", &emptyTags)
}) == ErrNone {
return true
}
if reqInfo.Owner {
return true
}
cred := reqInfo.Cred
if cred.AccessKey == "" {
p, err := globalPolicySys.Get(bucket)
if err != nil {
return false
}
values := getConditionValues(r, reqInfo.Region, auth.AnonymousCredentials)
for _, st := range p.Statements {
if st.Effect != policy.Allow {
continue
}
probe := st
probe.Conditions = nil
if probe.IsAllowed(policy.BucketPolicyArgs{Action: action, BucketName: bucket, ObjectName: object, ConditionValues: values}) {
return true
}
}
return false
}
if newGlobalAuthZPluginFn() != nil || !globalIAMSys.Initialized() {
return true
}
values := getConditionValues(r, "", cred)
for _, policies := range globalIAMSys.policySetsForExplicitDeny(cred) {
setAllows := false
for _, p := range policies {
for _, st := range p.Statements {
if st.Effect != policy.Allow {
continue
}
probe := st
probe.Conditions = nil
if probe.IsAllowed(policy.Args{
AccountName: cred.AccessKey,
Groups: cred.Groups,
Action: action,
BucketName: bucket,
ObjectName: object,
ConditionValues: values,
Claims: cred.Claims,
}) {
setAllows = true
break
}
}
if setAllows {
break
}
}
if len(policies) > 0 && !setAllows {
return false
}
if len(policies) == 0 && cred.ParentUser != globalActiveCred.AccessKey && cred.AccessKey != globalActiveCred.AccessKey {
return false
}
}
return true
}
func canReturnObjectTagsHeader(ctx context.Context, r *http.Request, versionID, existingTags string) bool {
reqInfo := logger.GetReqInfo(ctx)
if reqInfo == nil {
return false
}
prevVID := reqInfo.VersionID
reqInfo.VersionID = versionID
defer func() { reqInfo.VersionID = prevVID }()
if isActionExplicitlyDenied(ctx, r, policy.GetObjectTaggingAction, existingTags, nil) {
return false
}
if verAction, ok := objectVersionAction(policy.GetObjectTaggingAction, versionID); ok {
if isActionExplicitlyDenied(ctx, r, verAction, existingTags, nil) {
return false
}
}
if authorizeVersionedRequestWithExistingTags(ctx, r, policy.GetObjectTaggingAction, versionID, existingTags) == ErrNone {
return true
}
return reqInfo.Cred.AccessKey != "" &&
isRequestSignatureV4(r) &&
!isRequestPresignedSignatureV4(r) &&
authorizeRequestWithExistingTags(ctx, r, policy.ReplicateTagsAction, existingTags) == ErrNone
}
// 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)
}
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
}
+443
View File
@@ -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)
}
})
}
}
+31 -14
View File
@@ -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
@@ -435,7 +438,12 @@ func deleteObjectAction(versionID string) policy.Action {
func actionUsesObjectVersion(action policy.Action) bool {
switch action {
case policy.DeleteObjectAction, policy.DeleteObjectVersionAction,
policy.ReplicateDeleteAction, policy.BypassGovernanceRetentionAction:
policy.ReplicateDeleteAction, policy.BypassGovernanceRetentionAction,
policy.GetObjectAction, policy.GetObjectVersionAction,
policy.GetObjectAttributesAction, policy.GetObjectVersionAttributesAction,
policy.GetObjectTaggingAction, policy.GetObjectVersionTaggingAction,
policy.PutObjectTaggingAction, policy.PutObjectVersionTaggingAction,
policy.DeleteObjectTaggingAction, policy.DeleteObjectVersionTaggingAction:
return true
default:
return false
@@ -676,7 +684,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 +790,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
}
+118
View File
@@ -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)
}
})
}
}
+1 -1
View File
@@ -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) {
+152
View File
@@ -0,0 +1,152 @@
// Copyright (c) 2026 Feng Ruohang
// SPDX-License-Identifier: AGPL-3.0-or-later
package cmd
import (
"encoding/base64"
"fmt"
"maps"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/pgsty/silo-pkg/v3/policy"
)
// Use explicit condition values to exercise the evaluator, including literal
// spellings that S3 tag validation does not permit in stored tags.
func TestBatchPolicyConditionVariableParity(t *testing.T) {
f := newBatchAuthzFixture(t)
cases := []struct {
value, actual string
matches bool
requestOnly bool
}{
{"${$}100", "${$}100", true, false},
{"${$}100", "$100", false, false},
{"${*}", "${*}", true, false},
{"${?}", "${?}", true, false},
{"${custom:unknown}", "${custom:unknown}", true, false},
{"${aws:Unknown}", "${aws:Unknown}", true, false},
{"${aws:UserAgent", "${aws:UserAgent", true, false},
{"${aws:username}", "", true, false},
{"${aws:UserAgent}", "test-agent", true, true},
{"${prefix${aws:UserAgent}}", "${prefixtest-agent}", true, true},
}
for i, tc := range cases {
for opIndex, op := range []string{
"StringEquals", "StringNotEquals", "StringEqualsIgnoreCase", "StringNotEqualsIgnoreCase",
"StringLike", "StringNotLike", "ForAnyValue:StringEquals", "ForAllValues:StringEquals", "BinaryEquals",
} {
for _, effect := range []string{"Allow", "Deny"} {
t.Run(fmt.Sprintf("%d/%s/%s", i, op, effect), func(t *testing.T) {
name := fmt.Sprintf("var-%d-%d-%s", i, opIndex, effect)
conditionValue := tc.value
if op == "BinaryEquals" {
conditionValue = base64.StdEncoding.EncodeToString([]byte(conditionValue))
}
statement := fmt.Sprintf(`{"Effect":%q,"Action":["s3:GetObject"],"Resource":["arn:aws:s3:::batch-authz/data/*"],"Condition":{%q:{"s3:ExistingObjectTag/label":%q}}}`, effect, op, conditionValue)
if effect == "Deny" {
statement += `,{"Effect":"Allow","Action":["s3:GetObject"],"Resource":["arn:aws:s3:::batch-authz/data/*"]}`
}
cred, sub := batchContextUser(t, f, name, statement)
actual := tc.actual
if tc.value == "${aws:username}" {
actual = cred.AccessKey
}
values := map[string][]string{"ExistingObjectTag/label": {actual}}
ordinaryValues := maps.Clone(values)
ordinaryValues["username"] = []string{cred.AccessKey}
ordinaryValues["UserAgent"] = []string{"test-agent"}
ordinaryAllowed := f.sys.isAllowedLocally(policy.Args{
AccountName: cred.AccessKey,
Action: policy.GetObjectAction,
BucketName: f.bucket,
ObjectName: "data/" + name,
ConditionValues: ordinaryValues,
})
matches := tc.matches
if strings.Contains(op, "Not") {
matches = !matches
}
if want := matches == (effect == "Allow"); ordinaryAllowed != want {
t.Fatalf("ordinary evaluator allowed=%v want %v", ordinaryAllowed, want)
}
batchErr := sub.authorize(f.ctx, batchAuthzRequest{
action: policy.GetObjectAction, bucket: f.bucket, object: "data/" + name, values: values,
})
if tc.requestOnly {
if !isBatchJobAuthzError(batchErr) {
t.Fatalf("missing request context must deny: %v", batchErr)
}
if (effect == "Deny") != isBatchContextRefusal(batchErr) {
t.Fatalf("only a Deny must refuse missing context: %v", batchErr)
}
return
}
if (batchErr == nil) != ordinaryAllowed {
t.Fatalf("ordinary allowed=%v; batch=%v", ordinaryAllowed, batchErr)
}
})
}
}
}
// Condition keys themselves are never expanded.
const statement = `{"Effect":"Allow","Action":["s3:GetObject"],"Resource":["arn:aws:s3:::batch-authz/*"],"Condition":{"StringEquals":{"s3:ExistingObjectTag/${aws:UserAgent}":"literal"}}}`
cred, sub := batchContextUser(t, f, "literal-condition-key", statement)
values := map[string][]string{"ExistingObjectTag/${aws:UserAgent}": {"literal"}}
if !f.sys.isAllowedLocally(policy.Args{AccountName: cred.AccessKey, Action: policy.GetObjectAction, BucketName: f.bucket, ObjectName: "data/key", ConditionValues: values}) {
t.Fatal("ordinary evaluator did not match the literal condition key")
}
if err := sub.authorize(f.ctx, batchAuthzRequest{action: policy.GetObjectAction, bucket: f.bucket, object: "data/key", values: values}); err != nil {
t.Fatalf("batch treated a condition key as a variable: %v", err)
}
}
// Object names permit these spellings, so also compare real S3 GETs with batch.
func TestBatchPolicyResourceVariableParity(t *testing.T) {
f := newBatchAuthzFixture(t)
router := initTestAPIEndPoints(f.obj, []string{"GetObject"})
for i, raw := range []string{"${$}100", "${*}", "${?}", "${custom:unknown}", "${prefix${aws:UserAgent}}", "${aws:username}", "${aws:UserAgent}"} {
for _, effect := range []string{"Allow", "Deny"} {
t.Run(fmt.Sprintf("%d/%s", i, effect), func(t *testing.T) {
name := fmt.Sprintf("res-%d-%s", i, effect)
statement := fmt.Sprintf(`{"Effect":%q,"Action":["s3:GetObject"],"Resource":[%q]}`, effect, "arn:aws:s3:::batch-authz/"+raw)
if effect == "Deny" {
statement += `,{"Effect":"Allow","Action":["s3:GetObject"],"Resource":["arn:aws:s3:::batch-authz/*"]}`
}
cred, sub := batchContextUser(t, f, name, statement)
object := strings.ReplaceAll(raw, "${aws:username}", cred.AccessKey)
if raw == "${aws:UserAgent}" {
object = "test-agent"
}
_, err := f.obj.PutObject(f.ctx, f.bucket, object, mustGetPutObjReader(t, strings.NewReader("data"), 4, "", ""), ObjectOptions{})
mustIAM(t, err)
req, err := newTestSignedRequestV4(http.MethodGet, getGetObjectURL("", f.bucket, object), 0, nil, cred.AccessKey, cred.SecretKey, map[string]string{"User-Agent": "test-agent"})
mustIAM(t, err)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
wantStatus := http.StatusOK
if effect == "Deny" {
wantStatus = http.StatusForbidden
}
if rec.Code != wantStatus {
t.Fatalf("S3 read: %d want %d: %s", rec.Code, wantStatus, rec.Body.String())
}
err = sub.authorize(f.ctx, batchAuthzRequest{action: policy.GetObjectAction, bucket: f.bucket, object: object})
if raw == "${aws:UserAgent}" {
if !isBatchJobAuthzError(err) || (effect == "Deny") != isBatchContextRefusal(err) {
t.Fatalf("batch did not reject missing request context: %v", err)
}
return
}
if (err == nil) != (rec.Code == http.StatusOK) {
t.Fatalf("resource %q %s: S3=%d batch=%v", raw, effect, rec.Code, err)
}
})
}
}
}
+184 -58
View File
@@ -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) {
return batchJobAuthzError{reason: denied.reason + ": a policy statement depends on request context that a batch job does not have"}
}
}
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,6 +826,73 @@ 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 || batchConditionsNeedVariables(data, supplied) {
return true
}
}
return false
}
// batchUnsuppliedVariable follows Resource.Match: only CommonKeys are expanded,
// and an unsupported ${...} sequence (including its nested text) stays literal.
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 condition.CommonKeysMap[condition.KeyName(s[:j])] && !batchKeySupplied(supplied, s[:j]) {
return true
}
s = s[j+1:]
}
}
// Conditions replace each recognized variable wherever it occurs in a string
// value, unlike the resource parser. Inspect values only, not condition keys,
// and decode BinaryEquals as the policy evaluator does before substitution.
func batchConditionsNeedVariables(data []byte, supplied map[string]bool) bool {
var conditions map[string]map[string]condition.ValueSet
if err := json.Unmarshal(data, &conditions); err != nil {
return true
}
for name, keys := range conditions {
name = strings.TrimPrefix(strings.TrimPrefix(name, "ForAnyValue:"), "ForAllValues:")
name = strings.TrimSuffix(name, "IfExists")
switch name {
case "StringEquals", "StringNotEquals", "StringEqualsIgnoreCase", "StringNotEqualsIgnoreCase", "StringLike", "StringNotLike", "BinaryEquals":
default:
continue
}
for _, values := range keys {
for value := range values {
s, err := value.GetString()
if err != nil {
return true
}
if name == "BinaryEquals" {
decoded, err := base64.StdEncoding.DecodeString(s)
if err != nil {
return true
}
s = string(decoded)
}
for _, key := range condition.CommonKeys {
if strings.Contains(s, key.VarName()) && !batchKeySupplied(supplied, string(key)) {
return true
}
}
}
}
}
return false
}
+13 -1
View File
@@ -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
+79 -41
View File
@@ -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) {
ri.refuse(err)
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) {
ri.refuse(err)
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]
}
writeFn(batch)
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
}
+3 -2
View File
@@ -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,
+343
View File
@@ -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)
}
}
}
})
}
}
}
+177
View File
@@ -0,0 +1,177 @@
// Copyright (c) 2026 Feng Ruohang
// SPDX-License-Identifier: AGPL-3.0-or-later
package cmd
import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"maps"
"net/http"
"strings"
"testing"
"github.com/minio/minio/internal/crypto"
"github.com/minio/minio/internal/kms"
)
func TestBatchRotationContextValidation(t *testing.T) {
previous := GlobalKMS
GlobalKMS = nil
t.Cleanup(func() { GlobalKMS = previous })
for _, encoded := range []string{"not-base64!", base64.StdEncoding.EncodeToString([]byte(`{`)), base64.StdEncoding.EncodeToString([]byte(`{"tenant":1}`)), base64.StdEncoding.EncodeToString([]byte(`[]`))} {
enc := BatchJobKeyRotateEncryption{Type: ssekms, Key: "key", Context: encoded}
if err := enc.Validate(); err == nil {
t.Fatalf("accepted invalid context %q", encoded)
}
}
for _, raw := range []string{"", `{}`, `null`, `{"tenant":"acme"}`} {
encoded := base64.StdEncoding.EncodeToString([]byte(raw))
if raw == "" {
encoded = ""
}
if err := (BatchJobKeyRotateEncryption{Type: ssekms, Key: "key", Context: encoded}).Validate(); err != nil {
t.Fatalf("context %q: %v", raw, err)
}
}
}
func TestBatchRotationContextInitialRetryAndReload(t *testing.T) {
for _, mode := range []string{"initial", "retry", "reload"} {
t.Run(mode, func(t *testing.T) {
f := newBatchAuthzFixture(t)
previous := GlobalKMS
t.Cleanup(func() { GlobalKMS = previous })
configured, err := kms.NewBuiltin("context-key", bytes.Repeat([]byte{0x42}, 32))
mustIAM(t, err)
GlobalKMS = configured
const object = "rotation/context"
const payload = "a context-bound encryption key keeps this readable"
metadata := map[string]string{}
key, err := newEncryptMetadata(f.ctx, crypto.S3KMS, "context-key", nil, f.bucket, object, metadata, kms.Context{"tenant": "old"})
mustIAM(t, err)
ciphertext, err := io.ReadAll(crypto.EncryptSinglePart(strings.NewReader(payload), key))
mustIAM(t, err)
original, err := f.obj.PutObject(f.ctx, f.bucket, object, mustGetPutObjReader(t, bytes.NewReader(ciphertext), int64(len(ciphertext)), "", ""), ObjectOptions{Versioned: true, UserDefined: metadata})
mustIAM(t, err)
contextJSON := `{"tenant":"acme","purpose":"batch-rotation","区域":"杭州","�":"replacement","emoji":"🔑"}`
encoded := base64.StdEncoding.EncodeToString([]byte(contextJSON))
rec, job := f.submit(t, globalActiveCred, fmt.Sprintf("keyrotate:\n apiVersion: v1\n bucket: %s\n prefix: rotation/\n encryption:\n type: sse-kms\n key: context-key\n context: %s\n", f.bucket, encoded))
if job == nil {
t.Fatalf("submit: %d %s", rec.Code, rec.Body.String())
}
if mode == "retry" {
GlobalKMS = nil
err := job.KeyRotate.KeyRotate(f.ctx, f.obj, original, job.Submitter)
GlobalKMS = configured
if err == nil {
t.Fatal("rotation succeeded while KMS was unavailable")
}
before, err := f.obj.GetObjectInfo(f.ctx, f.bucket, object, ObjectOptions{VersionID: original.VersionID})
mustIAM(t, err)
if before.UserDefined[crypto.MetaContext] != metadata[crypto.MetaContext] {
t.Fatal("failed attempt changed context")
}
}
if mode == "reload" {
loaded := &BatchJobRequest{}
mustIAM(t, loaded.load(f.ctx, f.obj, getJobPath(*job)))
job = loaded
}
mustIAM(t, job.KeyRotate.Start(f.ctx, f.obj, *job))
progress := f.jobInfo(t, job)
if !progress.Complete || progress.Failed || progress.Objects != 1 {
t.Fatalf("rotation incomplete: %+v", progress)
}
after, err := f.obj.GetObjectInfo(f.ctx, f.bucket, object, ObjectOptions{VersionID: original.VersionID})
mustIAM(t, err)
raw, err := base64.StdEncoding.DecodeString(after.UserDefined[crypto.MetaContext])
mustIAM(t, err)
var got kms.Context
mustIAM(t, json.Unmarshal(raw, &got))
if !maps.Equal(got, kms.Context{"tenant": "acme", "purpose": "batch-rotation", "区域": "杭州", "�": "replacement", "emoji": "🔑"}) {
t.Fatalf("stored context %v", got)
}
actual, err := crypto.S3KMS.UnsealObjectKey(configured, after.UserDefined, f.bucket, object)
mustIAM(t, err)
if actual != key {
t.Fatal("rotation changed the plaintext data key")
}
if got := readCopyChecksumObject(t, f.obj, f.bucket, object, ObjectOptions{VersionID: original.VersionID}); string(got) != payload {
t.Fatal("rotated object is unreadable")
}
})
}
}
func TestBatchRotationInvalidUTF8LeavesObjectReadable(t *testing.T) {
for _, tc := range []struct{ name, raw string }{
{"colliding-keys", "{\"\xff\":\"a\",\"\xfe\":\"b\"}"},
{"replacement-key", "{\"\xff\":\"a\",\"�\":\"b\"}"},
{"invalid-value", "{\"tenant\":\"\xff\"}"},
} {
for _, phase := range []string{"submit", "reload"} {
t.Run(tc.name+"/"+phase, func(t *testing.T) {
f := newBatchAuthzFixture(t)
previous := GlobalKMS
t.Cleanup(func() { GlobalKMS = previous })
configured, err := kms.NewBuiltin("context-key", bytes.Repeat([]byte{0x42}, 32))
mustIAM(t, err)
GlobalKMS = configured
const object, payload = "rotation/context", "the original encrypted payload must remain readable"
metadata := map[string]string{}
key, err := newEncryptMetadata(f.ctx, crypto.S3KMS, "context-key", nil, f.bucket, object, metadata, kms.Context{"tenant": "old"})
mustIAM(t, err)
ciphertext, err := io.ReadAll(crypto.EncryptSinglePart(strings.NewReader(payload), key))
mustIAM(t, err)
original, err := f.obj.PutObject(f.ctx, f.bucket, object, mustGetPutObjReader(t, bytes.NewReader(ciphertext), int64(len(ciphertext)), "", ""), ObjectOptions{Versioned: true, UserDefined: metadata})
mustIAM(t, err)
encoded := base64.StdEncoding.EncodeToString([]byte(tc.raw))
submitContext := encoded
if phase == "reload" {
submitContext = base64.StdEncoding.EncodeToString([]byte(`{"tenant":"safe"}`))
}
rec, job := f.submit(t, globalActiveCred, fmt.Sprintf("keyrotate:\n apiVersion: v1\n bucket: %s\n prefix: rotation/\n encryption:\n type: sse-kms\n key: context-key\n context: %s\n", f.bucket, submitContext))
if phase == "submit" {
if job != nil || rec.Code != http.StatusBadRequest {
t.Fatalf("invalid UTF-8 context accepted: %d %s", rec.Code, rec.Body.String())
}
} else {
if job == nil {
t.Fatalf("valid submit: %d %s", rec.Code, rec.Body.String())
}
// Simulate a manifest saved by a version which accepted these
// bytes. Execution must validate again before changing the key.
job.KeyRotate.Encryption.Context = encoded
job.KeyRotate.Flags.Retry.Attempts = 1
manifest, err := job.MarshalMsg([]byte(batchJobEnvelope))
mustIAM(t, err)
mustIAM(t, saveConfig(f.ctx, f.obj, getJobPath(*job), manifest))
loaded := &BatchJobRequest{}
mustIAM(t, loaded.load(f.ctx, f.obj, getJobPath(*job)))
mustIAM(t, loaded.KeyRotate.Start(f.ctx, f.obj, *loaded))
progress := f.jobInfo(t, loaded)
if progress.Complete || !progress.Failed || progress.Objects != 0 || progress.ObjectsFailed != 1 {
t.Fatalf("invalid context reported as success: %+v", progress)
}
}
after, err := f.obj.GetObjectInfo(f.ctx, f.bucket, object, ObjectOptions{VersionID: original.VersionID})
mustIAM(t, err)
if after.UserDefined[crypto.MetaContext] != metadata[crypto.MetaContext] {
t.Fatal("rejected rotation changed stored context")
}
actual, err := crypto.S3KMS.UnsealObjectKey(configured, after.UserDefined, f.bucket, object)
mustIAM(t, err)
if actual != key {
t.Fatal("rejected rotation changed data key")
}
if got := readCopyChecksumObject(t, f.obj, f.bucket, object, ObjectOptions{VersionID: original.VersionID}); string(got) != payload {
t.Fatal("rejected rotation damaged stored content")
}
})
}
}
}
+155 -48
View File
@@ -29,10 +29,13 @@ import (
"strconv"
"strings"
"time"
"unicode/utf8"
jsoniter "github.com/json-iterator/go"
"github.com/minio/minio-go/v7/pkg/tags"
"github.com/minio/minio/internal/crypto"
"github.com/minio/minio/internal/etag"
"github.com/minio/minio/internal/hash"
xhttp "github.com/minio/minio/internal/http"
"github.com/minio/minio/internal/kms"
"github.com/pgsty/silo-pkg/v3/env"
@@ -83,43 +86,67 @@ const (
// BatchJobKeyRotateEncryption defines key rotation encryption options passed
type BatchJobKeyRotateEncryption struct {
Type BatchKeyRotationType `yaml:"type" json:"type"`
Key string `yaml:"key" json:"key"`
Context string `yaml:"context" json:"context"`
kmsContext kms.Context `msg:"-"`
Type BatchKeyRotationType `yaml:"type" json:"type"`
Key string `yaml:"key" json:"key"`
Context string `yaml:"context" json:"context"`
}
// batchKeyRotateContext decodes the persisted configuration on every run. It
// must not depend on unpersisted derived state or mutate shared job options.
func batchKeyRotateContext(encoded string) (kms.Context, error) {
ctx := kms.Context{}
if encoded == "" {
return ctx, nil
}
b, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return nil, err
}
// Saving the context replaces invalid UTF-8 and can collapse distinct
// keys, changing the associated data required to decrypt the object.
if !utf8.Valid(b) {
return nil, fmt.Errorf("KMS context must be valid UTF-8: %w", errInvalidArgument)
}
if err = jsoniter.ConfigCompatibleWithStandardLibrary.Unmarshal(b, &ctx); err != nil {
return nil, err
}
if ctx == nil {
ctx = kms.Context{}
}
return ctx, nil
}
// Validate validates input key rotation encryption options.
func (e BatchJobKeyRotateEncryption) Validate() error {
_, err := e.validate()
return err
}
// validate also returns a fresh context for the actual rotation. Validation's
// probe entry is added to a separate map, never to the object's context.
func (e BatchJobKeyRotateEncryption) validate() (kms.Context, error) {
if e.Type != sses3 && e.Type != ssekms {
return errInvalidArgument
return nil, errInvalidArgument
}
spaces := strings.HasPrefix(e.Key, " ") || strings.HasSuffix(e.Key, " ")
if e.Type == ssekms && spaces {
return crypto.ErrInvalidEncryptionKeyID
return nil, crypto.ErrInvalidEncryptionKeyID
}
if e.Type == ssekms && GlobalKMS != nil {
ctx := kms.Context{}
if e.Context != "" {
b, err := base64.StdEncoding.DecodeString(e.Context)
if err != nil {
return err
}
json := jsoniter.ConfigCompatibleWithStandardLibrary
if err := json.Unmarshal(b, &ctx); err != nil {
return err
}
}
e.kmsContext = kms.Context{}
maps.Copy(e.kmsContext, ctx)
ctx["MinIO batch API"] = "batchrotate" // Context for a test key operation
if _, err := GlobalKMS.GenerateKey(GlobalContext, &kms.GenerateKeyRequest{Name: e.Key, AssociatedData: ctx}); err != nil {
return err
if e.Type != ssekms {
return nil, nil
}
ctx, err := batchKeyRotateContext(e.Context)
if err != nil {
return nil, err
}
if GlobalKMS != nil {
probe := maps.Clone(ctx)
probe["MinIO batch API"] = "batchrotate" // Context for a test key operation
if _, err := GlobalKMS.GenerateKey(GlobalContext, &kms.GenerateKeyRequest{Name: e.Key, AssociatedData: probe}); err != nil {
return nil, err
}
}
return nil
return ctx, nil
}
// BatchKeyRotateFilter holds all the filters currently supported for batch replication
@@ -203,11 +230,11 @@ func (r *BatchJobKeyRotateV1) rotateAuthzRequests(oi ObjectInfo) []batchAuthzReq
if versionID == "" {
versionID = nullVersionID
}
// The encryption the rewrite leaves: rotation keeps the kind of the
// version (rotateKey), and SSE-KMS takes the requested key.
// The encryption the rewrite leaves: SSE-KMS takes the requested key,
// including when migrating an SSE-S3 version to SSE-KMS.
values := map[string][]string{}
switch {
case crypto.S3KMS.IsEncrypted(oi.UserDefined):
case crypto.S3KMS.IsEncrypted(oi.UserDefined) || r.Encryption.Type == ssekms:
values[strings.ToLower(xhttp.AmzServerSideEncryption)] = []string{xhttp.AmzEncryptionKMS}
if r.Encryption.Type == ssekms && r.Encryption.Key != "" {
values[strings.ToLower(xhttp.AmzServerSideEncryptionKmsID)] = []string{r.Encryption.Key}
@@ -285,31 +312,113 @@ func (r *BatchJobKeyRotateV1) KeyRotate(ctx context.Context, api ObjectLayer, ob
}
oi := obj.Clone()
var (
newKeyID string
newKeyContext kms.Context
newKeyID string
newKeyContext kms.Context
replaceContext bool
targetKind crypto.Type = crypto.S3
)
encMetadata := make(map[string]string)
for k, v := range oi.UserDefined {
if stringsHasPrefixFold(k, ReservedMetadataPrefixLower) {
encMetadata[k] = v
}
if sseKMS || r.Encryption.Type == ssekms {
targetKind = crypto.S3KMS
}
if (sseKMS || sseS3) && r.Encryption.Type == ssekms {
if err = r.Encryption.Validate(); err != nil {
newKeyContext, err = r.Encryption.validate()
if err != nil {
return err
}
newKeyID = strings.TrimPrefix(r.Encryption.Key, crypto.ARNPrefix)
newKeyContext = r.Encryption.kmsContext
}
if err = rotateKey(ctx, []byte{}, newKeyID, []byte{}, r.Bucket, oi.Name, encMetadata, newKeyContext); err != nil {
return err
replaceContext = r.Encryption.Context != ""
}
// Since we are rotating the keys, make sure to update the metadata.
oi.metadataOnly = true
oi.keyRotation = true
maps.Copy(oi.UserDefined, encMetadata)
srcOpts := ObjectOptions{VersionID: oi.VersionID}
dstOpts := ObjectOptions{
VersionID: oi.VersionID,
Versioned: versioned,
VersionSuspended: versionSuspended,
NoLock: true,
}
if oi.Legacy {
if targetKind == crypto.S3KMS && !replaceContext && len(newKeyContext) == 0 && sseKMS {
_, _, _, newKeyContext, err = crypto.S3KMS.ParseMetadata(oi.UserDefined)
if err != nil {
return err
}
}
readOpts := ObjectOptions{
VersionID: oi.VersionID,
Versioned: versioned,
VersionSuspended: versionSuspended,
NoLock: true,
NoAuditLog: true,
}
var gr *GetObjectReader
if z, ok := api.(*erasureServerPools); ok && pool >= 0 {
gr, err = z.serverPools[pool].GetObjectNInfo(ctx, r.Bucket, encodeDirObject(oi.Name), nil, http.Header{}, readOpts)
} else {
gr, err = api.GetObjectNInfo(ctx, r.Bucket, oi.Name, nil, http.Header{}, readOpts)
}
if err != nil {
return err
}
defer gr.Close()
actualSize, err := gr.ObjInfo.GetActualSize()
if err != nil {
return err
}
rawReader, err := hash.NewReader(ctx, gr, actualSize, "", "", actualSize)
if err != nil {
return err
}
pReader := NewPutObjReader(rawReader)
encMetadata := make(map[string]string)
encReader, objEncKey, err := newEncryptReader(ctx, rawReader, targetKind, newKeyID, nil, r.Bucket, oi.Name, encMetadata, newKeyContext)
if err != nil {
return err
}
crypto.RemoveInternalEntries(oi.UserDefined)
delete(oi.UserDefined, crypto.MetaContext)
delete(oi.UserDefined, ReservedMetadataPrefix+"compression")
delete(oi.UserDefined, ReservedMetadataPrefix+"actual-size")
maps.Copy(oi.UserDefined, encMetadata)
targetSize := (&ObjectInfo{Size: actualSize}).EncryptedSize()
wrapped := etag.Wrap(encReader, rawReader)
hReader, err := hash.NewReader(ctx, wrapped, targetSize, "", "", actualSize)
if err != nil {
return err
}
pReader, err = pReader.WithEncryption(hReader, &objEncKey)
if err != nil {
return err
}
oi.PutObjReader = pReader
oi.metadataOnly = false
oi.keyRotation = true
dstOpts.EncryptFn = metadataEncrypter(objEncKey)
if dstOpts.VersionID == "" && (versioned || versionSuspended) {
dstOpts.VersionID = nullVersionID
}
} else {
encMetadata := make(map[string]string)
for k, v := range oi.UserDefined {
if stringsHasPrefixFold(k, ReservedMetadataPrefixLower) {
encMetadata[k] = v
}
}
if err = rotateKeyWithTarget(ctx, []byte{}, targetKind, newKeyID, []byte{}, r.Bucket, oi.Name, encMetadata, newKeyContext, replaceContext); err != nil {
return err
}
// Since we are rotating the keys, make sure to update the metadata.
oi.metadataOnly = true
oi.keyRotation = true
delete(oi.UserDefined, crypto.MetaSealedKeyS3)
delete(oi.UserDefined, crypto.MetaSealedKeyKMS)
delete(oi.UserDefined, crypto.MetaKeyID)
delete(oi.UserDefined, crypto.MetaDataEncryptionKey)
delete(oi.UserDefined, crypto.MetaContext)
maps.Copy(oi.UserDefined, encMetadata)
}
// The rewrite replaces the stored metadata with UserDefined, which a
// read returns without the tags and the expiry: keep them.
if oi.UserTags != "" {
@@ -318,8 +427,6 @@ func (r *BatchJobKeyRotateV1) KeyRotate(ctx context.Context, api ObjectLayer, ob
if !oi.Expires.IsZero() {
oi.UserDefined["expires"] = oi.ExpiresStr()
}
srcOpts := ObjectOptions{VersionID: oi.VersionID}
dstOpts := ObjectOptions{VersionID: oi.VersionID, NoLock: true}
if z, ok := api.(*erasureServerPools); ok && pool >= 0 {
// Rewrite the copy that was read and authorized. The pools layer
// would route the rewrite by the latest version of the key, which
+3
View File
@@ -33,6 +33,9 @@ func newBatchAuthzFixture(t *testing.T) *batchAuthzFixture {
t.Helper()
ctx, sys := iamAdminFixture(t)
obj := newObjectLayerFn()
// Match storage-class parity to this fixture's single drive, even after
// an API test has loaded the configuration of a larger erasure set.
mustIAM(t, newTestConfig(globalMinioDefaultRegion, obj))
batchTestJobPool(ctx, t, obj)
f := &batchAuthzFixture{ctx: ctx, sys: sys, obj: obj, bucket: "batch-authz"}
mustIAM(t, obj.MakeBucket(ctx, f.bucket, MakeBucketOptions{VersioningEnabled: true}))
+124
View File
@@ -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)
}
}
+122 -57
View File
@@ -478,15 +478,11 @@ func (api objectAPIHandlers) DeleteMultipleObjectsHandler(w http.ResponseWriter,
getObjectInfoFn := objectAPI.GetObjectInfo
var (
hasLockEnabled bool
dsc ReplicateDecision
goi ObjectInfo
gerr error
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,8 +490,16 @@ func (api objectAPIHandlers) DeleteMultipleObjectsHandler(w http.ResponseWriter,
}
deleteResults := make([]deleteResult, len(deleteObjectsReq.Objects))
duplicateOf := make(map[int]int)
vc, _ := globalBucketVersioningSys.Get(bucket)
vc, rcfg, metaErr := checkBucketDeleteMetadata(bucket)
if metaErr != nil {
writeErrorResponse(ctx, w, toAPIError(ctx, metaErr), r.URL)
return
}
lockedNullDelete := func(obj ObjectToDelete) bool {
return obj.VersionID == "" && rcfg.LockEnabled && !vc.PrefixEnabled(obj.ObjectName)
}
oss := make([]*objSweeper, len(deleteObjectsReq.Objects))
for index, object := range deleteObjectsReq.Objects {
reqInfo.ObjectName = object.ObjectName
@@ -523,13 +527,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 != "" || lockedNullDelete(object) {
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 +564,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 +581,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 +603,72 @@ func (api objectAPIHandlers) DeleteMultipleObjectsHandler(w http.ResponseWriter,
ctx = bgContext(ctx)
deleteList := toNames(objectsToDelete)
dObjects, errs := deleteObjectsFn(ctx, bucket, deleteList, ObjectOptions{
PrefixEnabledFn: vc.PrefixEnabled,
VersionSuspended: vc.Suspended(),
})
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 == "" && !lockedNullDelete(object) {
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(),
RefuseLockedOverwrite: lockedNullDelete(object),
}
index := objectsToDelete[object]
if !globalTierConfigMgr.Empty() {
sweepVID := object.VersionID
if sweepVID == "" && opts.VersionSuspended {
sweepVID = nullVersionID
}
oss[index] = newObjSweeper(bucket, object.ObjectName).WithVersion(sweepVID).WithVersioning(opts.Versioned, opts.VersionSuspended)
}
opts.SetEvalRetentionBypassFn(func(oi ObjectInfo, err error) error {
checkObj := object
if checkObj.VersionID == "" {
checkObj.VersionID = nullVersionID
}
return enforceRetentionBypassForDelete(ctx, r, bucket, checkObj, 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)
delObj := DeletedObject{
ObjectName: object.ObjectName,
VersionID: object.VersionID,
ReplicationState: oi.ReplicationState(),
}
if object.VersionID == "" && oi.DeleteMarker {
delObj.DeleteMarker = true
delObj.DeleteMarkerVersionID = oi.VersionID
delObj.DeleteMarkerMTime = DeleteMarkerMTime{oi.ModTime}
}
dObjects[i] = delObj
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 +677,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 == "" && !lockedNullDelete(deleteList[i]) {
dObjects[i].ReplicationState = deleteList[i].ReplicationState()
}
deleteResults[dindex].delInfo = dObjects[i]
@@ -638,20 +695,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 +725,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 +768,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()
+13 -5
View File
@@ -90,8 +90,14 @@ func (api objectAPIHandlers) listObjectVersionsHandler(w http.ResponseWriter, r
}
var checkObjMeta metaCheckFn
if metadata {
checkObjMeta = func(name string, action policy.Action) (s3Err APIErrorCode) {
return checkRequestAuthType(ctx, r, action, bucket, name)
checkObjMeta = func(obj ObjectInfo, action policy.Action) (s3Err APIErrorCode) {
vid := obj.VersionID
if vid == "" {
vid = nullVersionID
}
return withScopedReqObject(ctx, bucket, obj.Name, vid, func() APIErrorCode {
return authorizeVersionedRequestWithExistingTags(ctx, r, action, vid, obj.UserTags)
})
}
}
urlValues := r.Form
@@ -176,8 +182,10 @@ func (api objectAPIHandlers) listObjectsV2Handler(ctx context.Context, w http.Re
var checkObjMeta metaCheckFn
if metadata {
checkObjMeta = func(name string, action policy.Action) (s3Err APIErrorCode) {
return checkRequestAuthType(ctx, r, action, bucket, name)
checkObjMeta = func(obj ObjectInfo, action policy.Action) (s3Err APIErrorCode) {
return withScopedReqObject(ctx, bucket, obj.Name, "", func() APIErrorCode {
return authorizeVersionedRequestWithExistingTags(ctx, r, action, "", obj.UserTags)
})
}
}
urlValues := r.Form
@@ -202,7 +210,7 @@ func (api objectAPIHandlers) listObjectsV2Handler(ctx context.Context, w http.Re
if r.Header.Get(xMinIOExtract) == "true" && strings.Contains(prefix, archivePattern) {
// Initiate a list objects operation inside a zip file based in the input params
listObjectsV2Info, err = listObjectsV2InArchive(ctx, objectAPI, bucket, prefix, token, delimiter, maxKeys, startAfter, r.Header)
listObjectsV2Info, err = listObjectsV2InArchive(ctx, objectAPI, bucket, prefix, token, delimiter, maxKeys, startAfter, r)
} else {
// Initiate a list objects operation based on the input params.
// On success would return back ListObjectsInfo object to be
+125
View File
@@ -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())
}
}
}
}
+6 -1
View File
@@ -182,8 +182,13 @@ func bucketConfigPayload(bucket, file string, data []byte, lockEnabled bool) ([]
key := data
switch file {
case bucketPolicyConfig:
// Replication uses explicit null as a tombstone; ordinary policy PUTs
// validate their JSON before entering this metadata path.
if bytes.Equal(bytes.TrimSpace(data), []byte("null")) {
return nil, nil, nil
}
var cfg *policy.BucketPolicy
cfg, err = policy.ParseBucketPolicyConfig(bytes.NewReader(data), bucket)
cfg, err = parseBucketPolicyStrictBytes(data, bucket)
if err == nil {
if cfg.IsEmpty() {
return nil, nil, nil
+8 -3
View File
@@ -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
}
+145 -5
View File
@@ -28,11 +28,27 @@ import (
"github.com/minio/minio/internal/amztime"
"github.com/minio/minio/internal/auth"
objectlock "github.com/minio/minio/internal/bucket/object/lock"
"github.com/minio/minio/internal/bucket/versioning"
xhttp "github.com/minio/minio/internal/http"
"github.com/minio/minio/internal/logger"
"github.com/pgsty/silo-pkg/v3/policy"
)
// checkBucketDeleteMetadata loads bucket versioning and Object Lock configs
// fail-closed so delete handlers never fall back to unversioned/unlocked
// defaults when bucket metadata cannot be read.
func checkBucketDeleteMetadata(bucketName string) (*versioning.Versioning, objectlock.Retention, error) {
vc, err := globalBucketVersioningSys.Get(bucketName)
if err != nil {
return nil, objectlock.Retention{}, err
}
rcfg, err := globalBucketObjectLockSys.Get(bucketName)
if err != nil {
return nil, objectlock.Retention{}, err
}
return vc, rcfg, nil
}
// BucketObjectLockSys - map of bucket and retention configuration.
type BucketObjectLockSys struct{}
@@ -354,6 +370,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 +490,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 +606,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 +658,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 +750,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
+1 -2
View File
@@ -18,7 +18,6 @@
package cmd
import (
"bytes"
"io"
"net/http"
@@ -83,7 +82,7 @@ func (api objectAPIHandlers) PutBucketPolicyHandler(w http.ResponseWriter, r *ht
return
}
bucketPolicy, err := policy.ParseBucketPolicyConfig(bytes.NewReader(bucketPolicyBytes), bucket)
bucketPolicy, err := parseBucketPolicyStrictBytes(bucketPolicyBytes, bucket)
if err != nil {
writeErrorResponse(ctx, w, APIError{
Code: "MalformedPolicy",
+13 -2
View File
@@ -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"
}
+114 -105
View File
@@ -2673,6 +2673,7 @@ func proxyHeadToRepTarget(ctx context.Context, bucket, object string, rs *HTTPRa
},
PartNumber: opts.PartNumber,
}
gopts.Set(xhttp.AmzTagDirective, accessDirective)
if rs != nil {
h, err := rs.ToHeader()
if err != nil {
@@ -2773,127 +2774,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{
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)
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"},
})
})
}
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
}
present = append(present, tgt)
}
// don't return error if at least one target was tagged successfully
if taggedCount == 0 && terr != nil {
proxy.Err = terr
if readErr != nil {
return nil, proxyResult{Err: readErr}, ErrNone
}
return proxy
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
}
// 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)
targets, proxy, authErr := readTaggingProxyTargets(ctx, bucket, object, opts, proxyTargets, authorize)
if authErr != ErrNone || proxy.Err != nil {
return nil, proxy, authErr
}
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) {
+156
View File
@@ -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)
}
},
})
}
+346
View File
@@ -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")
}
})
}
}
+57
View File
@@ -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())
}
}
},
})
}
+63 -41
View File
@@ -260,6 +260,15 @@ func ParseSSECustomerHeader(header http.Header) (key []byte, err error) {
// This function rotates old to new key.
func rotateKey(ctx context.Context, oldKey []byte, newKeyID string, newKey []byte, bucket, object string, metadata map[string]string, cryptoCtx kms.Context) error {
kind, _ := crypto.IsEncrypted(metadata)
return rotateKeyWithTarget(ctx, oldKey, kind, newKeyID, newKey, bucket, object, metadata, cryptoCtx, len(cryptoCtx) > 0)
}
func rotateKeyWithTarget(ctx context.Context, oldKey []byte, targetKind crypto.Type, newKeyID string, newKey []byte, bucket, object string, metadata map[string]string, cryptoCtx kms.Context, replaceContext bool) error {
kind, _ := crypto.IsEncrypted(metadata)
if targetKind == nil {
targetKind = kind
}
var objectKey crypto.ObjectKey
switch kind {
case crypto.S3:
if GlobalKMS == nil {
@@ -269,7 +278,7 @@ func rotateKey(ctx context.Context, oldKey []byte, newKeyID string, newKey []byt
if err != nil {
return err
}
oldKey, err := GlobalKMS.Decrypt(ctx, &kms.DecryptRequest{
unsealKey, err := GlobalKMS.Decrypt(ctx, &kms.DecryptRequest{
Name: keyID,
Ciphertext: kmsKey,
AssociatedData: kms.Context{bucket: path.Join(bucket, object)},
@@ -277,66 +286,30 @@ func rotateKey(ctx context.Context, oldKey []byte, newKeyID string, newKey []byt
if err != nil {
return err
}
var objectKey crypto.ObjectKey
if err = objectKey.Unseal(oldKey, sealedKey, crypto.S3.String(), bucket, object); err != nil {
if err = objectKey.Unseal(unsealKey, sealedKey, crypto.S3.String(), bucket, object); err != nil {
return err
}
newKey, err := GlobalKMS.GenerateKey(ctx, &kms.GenerateKeyRequest{
Name: GlobalKMS.DefaultKey,
AssociatedData: kms.Context{bucket: path.Join(bucket, object)},
})
if err != nil {
return err
}
sealedKey = objectKey.Seal(newKey.Plaintext, crypto.GenerateIV(rand.Reader), crypto.S3.String(), bucket, object)
crypto.S3.CreateMetadata(metadata, newKey.KeyID, newKey.Ciphertext, sealedKey)
return nil
case crypto.S3KMS:
if GlobalKMS == nil {
return errKMSNotConfigured
}
objectKey, err := crypto.S3KMS.UnsealObjectKey(GlobalKMS, metadata, bucket, object)
var err error
objectKey, err = crypto.S3KMS.UnsealObjectKey(GlobalKMS, metadata, bucket, object)
if err != nil {
return err
}
if len(cryptoCtx) == 0 {
if targetKind == crypto.S3KMS && !replaceContext && len(cryptoCtx) == 0 {
_, _, _, cryptoCtx, err = crypto.S3KMS.ParseMetadata(metadata)
if err != nil {
return err
}
}
// If the context does not contain the bucket key
// we must add it for key generation. However,
// the context must be stored exactly like the
// client provided it. Therefore, we create a copy
// of the client provided context and add the bucket
// key, if not present.
kmsCtx := kms.Context{}
maps.Copy(kmsCtx, cryptoCtx)
if _, ok := kmsCtx[bucket]; !ok {
kmsCtx[bucket] = path.Join(bucket, object)
}
newKey, err := GlobalKMS.GenerateKey(ctx, &kms.GenerateKeyRequest{
Name: newKeyID,
AssociatedData: kmsCtx,
})
if err != nil {
return err
}
sealedKey := objectKey.Seal(newKey.Plaintext, crypto.GenerateIV(rand.Reader), crypto.S3KMS.String(), bucket, object)
crypto.S3KMS.CreateMetadata(metadata, newKey.KeyID, newKey.Ciphertext, sealedKey, cryptoCtx)
return nil
case crypto.SSEC:
sealedKey, err := crypto.SSEC.ParseMetadata(metadata)
if err != nil {
return err
}
var objectKey crypto.ObjectKey
if err = objectKey.Unseal(oldKey, sealedKey, crypto.SSEC.String(), bucket, object); err != nil {
if subtle.ConstantTimeCompare(oldKey, newKey) == 1 {
return errInvalidSSEParameters // AWS returns special error for equal but invalid keys.
@@ -353,6 +326,55 @@ func rotateKey(ctx context.Context, oldKey []byte, newKeyID string, newKey []byt
default:
return errObjectTampered
}
switch targetKind {
case crypto.S3:
genKey, err := GlobalKMS.GenerateKey(ctx, &kms.GenerateKeyRequest{
Name: GlobalKMS.DefaultKey,
AssociatedData: kms.Context{bucket: path.Join(bucket, object)},
})
if err != nil {
return err
}
delete(metadata, crypto.MetaSealedKeyKMS)
delete(metadata, crypto.MetaKeyID)
delete(metadata, crypto.MetaDataEncryptionKey)
delete(metadata, crypto.MetaContext)
sealedKey := objectKey.Seal(genKey.Plaintext, crypto.GenerateIV(rand.Reader), crypto.S3.String(), bucket, object)
crypto.S3.CreateMetadata(metadata, genKey.KeyID, genKey.Ciphertext, sealedKey)
return nil
case crypto.S3KMS:
// If the context does not contain the bucket key
// we must add it for key generation. However,
// the context must be stored exactly like the
// client provided it. Therefore, we create a copy
// of the client provided context and add the bucket
// key, if not present.
kmsCtx := kms.Context{}
maps.Copy(kmsCtx, cryptoCtx)
if _, ok := kmsCtx[bucket]; !ok {
kmsCtx[bucket] = path.Join(bucket, object)
}
genKey, err := GlobalKMS.GenerateKey(ctx, &kms.GenerateKeyRequest{
Name: newKeyID,
AssociatedData: kmsCtx,
})
if err != nil {
if errors.Is(err, kes.ErrKeyNotFound) {
return errKMSKeyNotFound
}
return err
}
delete(metadata, crypto.MetaSealedKeyS3)
delete(metadata, crypto.MetaKeyID)
delete(metadata, crypto.MetaDataEncryptionKey)
delete(metadata, crypto.MetaContext)
sealedKey := objectKey.Seal(genKey.Plaintext, crypto.GenerateIV(rand.Reader), crypto.S3KMS.String(), bucket, object)
crypto.S3KMS.CreateMetadata(metadata, genKey.KeyID, genKey.Ciphertext, sealedKey, cryptoCtx)
return nil
default:
return errInvalidEncryptionParameters
}
}
func newEncryptMetadata(ctx context.Context, kind crypto.Type, keyID string, key []byte, bucket, object string, metadata map[string]string, cryptoCtx kms.Context) (crypto.ObjectKey, error) {
+3
View File
@@ -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() {
+60 -6
View File
@@ -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) {
@@ -935,11 +963,14 @@ func (er erasureObjects) getObjectFileInfo(ctx context.Context, bucket, object s
// to the disks; this value is not accurate because we do
// not know the storage class of the object yet
minDisks := 0
if p := globalStorageClass.GetParityForSC(""); p > -1 {
if p := globalStorageClass.GetParityForSC(""); p > -1 && p < er.setDriveCount {
minDisks = er.setDriveCount - p
} else {
minDisks = er.setDriveCount - er.defaultParityCount
}
if minDisks < 1 {
minDisks = 1
}
if minDisks == er.setDriveCount/2 {
// when data and parity are same we must atleast
@@ -1722,6 +1753,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) {
@@ -2186,7 +2220,7 @@ func (er erasureObjects) DeleteObject(ctx context.Context, bucket, object string
// A restored-copy expiry needs fresh metadata to confirm its target
// and deadline, a checked expiry to guard its protection; ordinary
// unversioned DELETE may still try a blind delete.
if opts.Transition.ExpireRestored || checkedExpiry || opts.Versioned || opts.VersionSuspended || countOnlineDisks(storageDisks) < len(storageDisks)/2+1 {
if opts.Transition.ExpireRestored || checkedExpiry || opts.Versioned || opts.VersionSuspended || opts.RefuseLockedOverwrite || countOnlineDisks(storageDisks) < len(storageDisks)/2+1 {
// Add an MRF heal for next time.
er.addPartial(bucket, object, opts.VersionID)
return objInfo, InsufficientWriteQuorum{}
@@ -2237,8 +2271,28 @@ func (er erasureObjects) DeleteObject(ctx context.Context, bucket, object string
}
}
// When versioning is disabled or suspended for this key, an unaddressed
// delete overwrites or removes the null version even if a newer non-null
// version is returned as latest by getObjectInfoAndQuorum above.
retOI, retErr := goi, gerr
if !opts.Versioned && !opts.DeleteMarker && opts.VersionID == "" && (opts.EvalRetentionBypassFn != nil || opts.RefuseLockedOverwrite) {
if gerr == nil && goi.VersionID != "" && goi.VersionID != nullVersionID {
nullOpts := opts
nullOpts.VersionID = nullVersionID
retOI, _, retErr = er.getObjectInfoAndQuorum(ctx, bucket, object, nullOpts)
if _, ok := retErr.(InsufficientReadQuorum); ok && opts.RefuseLockedOverwrite {
er.addPartial(bucket, object, nullVersionID)
return objInfo, InsufficientWriteQuorum{}
}
}
}
if opts.EvalRetentionBypassFn != nil {
if err := opts.EvalRetentionBypassFn(goi, gerr); err != nil {
if err := opts.EvalRetentionBypassFn(retOI, retErr); err != nil {
return ObjectInfo{}, err
}
} else if opts.RefuseLockedOverwrite && !opts.Versioned && !opts.DeleteMarker && opts.VersionID == "" {
if err := refuseLockedMarkerOverwrite(ctx, bucket, object, nullVersionID, retOI, retErr); err != nil {
return ObjectInfo{}, err
}
}
@@ -2560,7 +2614,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
+57 -18
View File
@@ -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
@@ -635,6 +646,26 @@ func (z *erasureServerPools) deleteObjectReconciled(ctx context.Context, bucket,
case opts.VersionID != "":
// An addressed version already resolved every copy above.
logical = mergedPoolObjectInfo(copies)
case !opts.Versioned && !opts.DeleteMarker:
// An unqualified delete when versioning is not active for this key
// removes or replaces the null version in every pool that holds one,
// even if a newer non-null version is current for reads.
nullOpts := opts
nullOpts.VersionID = nullVersionID
nullCopies, err := z.objectPoolInfos(ctx, bucket, object, nullOpts)
switch {
case err == nil:
if nullCopies[0].ObjInfo.DeleteMarker {
gerr = toObjectErr(errFileNotFound, bucket, object)
logical = nullCopies[0].ObjInfo
} else {
logical = mergedPoolObjectInfo(nullCopies)
}
case isErrObjectNotFound(err) || isErrVersionNotFound(err):
gerr = toObjectErr(errFileNotFound, bucket, object)
default:
return ObjectInfo{}, err
}
default:
versions, err := z.metadataPoolInfos(ctx, bucket, object, opts)
if err != nil {
@@ -650,6 +681,14 @@ func (z *erasureServerPools) deleteObjectReconciled(ctx context.Context, bucket,
return ObjectInfo{}, err
}
opts.EvalRetentionBypassFn = nil
if !opts.Versioned && !opts.DeleteMarker && opts.VersionID == "" {
opts.RefuseLockedOverwrite = false
}
} else if opts.RefuseLockedOverwrite && !opts.Versioned && !opts.DeleteMarker && opts.VersionID == "" {
if err := refuseLockedMarkerOverwrite(ctx, bucket, object, nullVersionID, logical, gerr); err != nil {
return ObjectInfo{}, err
}
opts.RefuseLockedOverwrite = false
}
if opts.EvalMetadataFn != nil {
decision, err := opts.EvalMetadataFn(&logical, gerr)
@@ -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)
}
}
+135 -35
View File
@@ -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
if err := ctx.Err(); err != nil {
return err
}
for _, set := range z.serverPools[idx].sets {
if err := set.listObjectsToDecommission(ctx, bi, func(entry metaCacheEntry) {
if !entry.isObject() {
return
disks := set.getDisks()
if len(disks) == 0 {
return toObjectErr(errErasureReadQuorum, bi.Name)
}
readQuorum := set.migrationReadQuorum(len(disks))
onlineCount := 0
for _, disk := range disks {
if disk == nil {
continue
}
if bi.Name == minioMetaBucket && strings.Contains(entry.name, dataUsageCacheName) {
return
// 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)
}
}
}
fivs, err := entry.fileInfoVersions(bi.Name)
if err != nil {
return
if diskOffline {
continue
}
versionsFound += len(fivs.Versions) + len(fivs.FreeVersions)
}); err != nil {
return err
onlineCount++
}
if onlineCount < readQuorum {
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)
}
return nil
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,180 @@ 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 normalized current header orders the merge.
wantHeader := current(0).header
wantHeader.Flags &^= xlFlagUsesDataDir
if versionRevision(old(0), xlMetaVersion).header != wantHeader {
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 with only the time-derived flag removed.
wantHeader := xl.versions[i].header
wantHeader.Flags &^= xlFlagUsesDataDir
if versionRevision(xl.versions[i], xl.metaV).header != wantHeader {
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 +1264,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 +1671,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"
+23 -19
View File
@@ -187,9 +187,18 @@ 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, except UsesDataDir, which can
// change solely because a restored copy expired before healing. 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 +276,13 @@ 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)
key.header.Flags &^= xlFlagUsesDataDir
}
return key
}
@@ -350,7 +364,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 +589,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 +615,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")
}
}
+141 -1
View File
@@ -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
@@ -0,0 +1,205 @@
// Copyright (c) 2026 PGSTY
//
// 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"
"fmt"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
xhttp "github.com/minio/minio/internal/http"
)
type copyPartSourceContextKey struct{}
// Pause the HTTP copy after the real reader has acquired its source lock.
// IAM and other background readers do not carry the request's context key.
type copyPartSourceGate struct {
ObjectLayer
opened, release chan struct{}
}
func (a *copyPartSourceGate) GetObjectNInfo(ctx context.Context, bucket, object string, rs *HTTPRangeSpec, h http.Header, opts ObjectOptions) (*GetObjectReader, error) {
gr, err := a.ObjectLayer.GetObjectNInfo(ctx, bucket, object, rs, h, opts)
if err != nil || ctx.Value(copyPartSourceContextKey{}) != true {
return gr, err
}
close(a.opened)
select {
case <-a.release:
return gr, nil
case <-ctx.Done():
gr.Close()
return nil, ctx.Err()
}
}
func copyPartLockRefs(l *localLockInstance) int32 {
l.ns.lockMapMutex.Lock()
defer l.ns.lockMapMutex.Unlock()
if lock := l.ns.lockMap[pathJoin(l.volume, l.paths[0])]; lock != nil {
return lock.ref
}
return 0
}
// A copy from the upload's own key holds object RLock before asking for the
// upload RLock. Complete must wait for that reader without holding upload
// Lock, on both single- and multi-pool backends. Cancellation of either
// request must release its locks and let the other request finish.
func TestUploadPartCopyConcurrentComplete(t *testing.T) {
for _, pools := range []int{1, 2} {
for _, action := range []string{"complete", "cancel-copy", "cancel-complete"} {
t.Run(fmt.Sprintf("pools=%d/%s", pools, action), func(t *testing.T) {
z, _ := bulkDeletePools(t, pools)
ctx, cancel := context.WithTimeout(t.Context(), 15*time.Second)
var workers sync.WaitGroup
defer func() { cancel(); workers.Wait() }()
api := &copyPartSourceGate{ObjectLayer: z, opened: make(chan struct{}), release: make(chan struct{})}
bucket, router, err := initAPIHandlerTest(ctx, api, nil, MakeBucketOptions{})
if err != nil {
t.Fatal(err)
}
setObjectLayer(api)
const key = "copy-part-self"
data := bytes.Repeat([]byte("existing source bytes"), 32768)
oi, err := z.PutObject(ctx, bucket, key, mustGetPutObjReader(t, bytes.NewReader(data), int64(len(data)), "", ""), ObjectOptions{})
if err != nil {
t.Fatal(err)
}
if oi.Inlined {
t.Fatal("the source must retain its read lock until its reader closes")
}
upload, err := z.NewMultipartUpload(ctx, bucket, key, ObjectOptions{})
if err != nil {
t.Fatal(err)
}
first := []byte("the completed part")
part, err := z.PutObjectPart(ctx, bucket, key, upload.UploadID, 1, mustGetPutObjReader(t, bytes.NewReader(first), int64(len(first)), "", ""), ObjectOptions{})
if err != nil {
t.Fatal(err)
}
objectLock := z.NewObjectNSLock(bucket, key).(*localLockInstance)
uploadLock := z.uploadIDNSLock(bucket, key, upload.UploadID).(*localLockInstance)
copyCtx, cancelCopy := context.WithCancel(context.WithValue(ctx, copyPartSourceContextKey{}, true))
defer cancelCopy()
req, err := newTestSignedRequestV4(http.MethodPut, getPutObjectPartURL("", bucket, key, upload.UploadID, "2"), 0, nil, globalActiveCred.AccessKey, globalActiveCred.SecretKey, map[string]string{xhttp.AmzCopySource: "/" + bucket + "/" + key})
if err != nil {
t.Fatal(err)
}
copyDone := make(chan *httptest.ResponseRecorder, 1)
workers.Go(func() {
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req.WithContext(copyCtx))
copyDone <- rec
})
select {
case <-api.opened:
case <-ctx.Done():
t.Fatal("COPY did not open its source")
}
if refs := copyPartLockRefs(objectLock); refs != 1 {
t.Fatalf("COPY object lock references = %d, want 1", refs)
}
completeCtx, cancelComplete := context.WithCancel(ctx)
defer cancelComplete()
completeDone := make(chan error, 1)
workers.Go(func() {
_, err := z.CompleteMultipartUpload(completeCtx, bucket, key, upload.UploadID, []CompletePart{{PartNumber: 1, ETag: part.ETag}}, ObjectOptions{})
completeDone <- err
})
// One reference belongs to COPY, the second to Complete waiting
// for it. Check the actual lock resources, not elapsed time.
ticker := time.NewTicker(time.Millisecond)
defer ticker.Stop()
for copyPartLockRefs(objectLock) != 2 {
select {
case err := <-completeDone:
t.Fatalf("Complete exited before waiting for COPY: %v", err)
case <-ctx.Done():
t.Fatal("Complete did not wait for the source object lock")
case <-ticker.C:
}
}
if refs := copyPartLockRefs(uploadLock); refs != 0 {
t.Fatalf("Complete holds the upload lock while waiting for COPY's object lock: refs=%d", refs)
}
if action == "cancel-complete" {
cancelComplete()
select {
case err := <-completeDone:
if !errors.Is(err, context.Canceled) {
t.Fatalf("canceled Complete: %v", err)
}
case <-ctx.Done():
t.Fatal("Complete did not cancel while COPY held the source")
}
}
if action == "cancel-copy" {
cancelCopy()
} else {
close(api.release)
}
select {
case rec := <-copyDone:
want := http.StatusOK
if action == "cancel-copy" {
want = 499
}
if rec.Code != want {
t.Fatalf("COPY: %d, want %d: %s", rec.Code, want, rec.Body.String())
}
case <-ctx.Done():
t.Fatal("COPY did not finish")
}
if action != "cancel-complete" {
select {
case err := <-completeDone:
if err != nil {
t.Fatalf("Complete after source release: %v", err)
}
case <-ctx.Done():
t.Fatal("Complete did not finish after source release")
}
}
if copyPartLockRefs(objectLock) != 0 || copyPartLockRefs(uploadLock) != 0 {
t.Fatal("request left an object or upload lock behind")
}
wantBody := string(first)
if action == "cancel-complete" {
wantBody = string(data)
if _, err := z.GetMultipartInfo(ctx, bucket, key, upload.UploadID, ObjectOptions{}); err != nil {
t.Fatalf("canceled completion lost the upload: %v", err)
}
}
if body := bulkDeleteBody(t, z, bucket, key, ""); body != wantBody {
t.Fatalf("object body length = %d, want %d", len(body), len(wantBody))
}
})
}
}
}
@@ -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 {
+20 -14
View File
@@ -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
+107 -37
View File
@@ -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)
@@ -1318,7 +1320,9 @@ func (z *erasureServerPools) DeleteObject(ctx context.Context, bucket string, ob
approvedExpiry := opts.Expiration.Expire && opts.Expiration.CheckedAllPools && !opts.Transition.ExpireRestored
reconcileVersion := opts.VersionID != "" && !opts.DataMovement &&
(!opts.ReplicationRequest || opts.isVersionPurge()) && (!opts.Expiration.Expire || approvedExpiry) && !opts.InclFreeVersions
if !z.SinglePool() && (opts.CheckPrecondFn != nil || reconcileVersion) {
reconcileUnaddressedLock := opts.VersionID == "" && !opts.Versioned && !opts.DeleteMarker &&
opts.RefuseLockedOverwrite && !opts.DataMovement && !opts.ReplicationRequest && !opts.Expiration.Expire && !opts.InclFreeVersions
if !z.SinglePool() && (opts.CheckPrecondFn != nil || reconcileVersion || reconcileUnaddressedLock) {
return z.deleteObjectReconciled(ctx, bucket, object, opts)
}
@@ -1543,6 +1547,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
@@ -1666,6 +1691,51 @@ func (z *erasureServerPools) DeleteObjects(ctx context.Context, bucket string, o
return dobjects, derrs
}
// copyObjectPlan fixes placement before a caller prepares encrypted or compressed
// bytes. A reference can only be made in the pool holding the source version.
type copyObjectPlan struct {
poolIdx int
rewrite bool
}
// prepareCopyObject requires the same source/destination locks as CopyObject.
// In-place changes address the exact version; new versions retain the normal
// write placement and rewrite the data when it lives in a different pool.
func (z *erasureServerPools) prepareCopyObject(ctx context.Context, srcBucket, srcObject, dstBucket, dstObject string, srcInfo ObjectInfo, srcOpts, dstOpts ObjectOptions) (copyObjectPlan, error) {
if err := checkCopyObjArgs(ctx, dstBucket, dstObject); err != nil {
return copyObjectPlan{}, err
}
srcObject, dstObject = encodeDirObject(srcObject), encodeDirObject(dstObject)
sameObject := isStringEqual(pathJoin(srcBucket, srcObject), pathJoin(dstBucket, dstObject))
reference := sameObject && !copyRewritesObjectData(srcInfo.metadataOnly && !srcInfo.Legacy, srcOpts, dstOpts)
inPlace := reference && (dstOpts.VersionID != "" && srcOpts.VersionID == dstOpts.VersionID || !dstOpts.Versioned && srcOpts.VersionID == "")
if inPlace {
// Metadata must stay with the authoritative source bytes, including
// when that pool is draining. Write allocation could select a stale
// duplicate and later retire the only current copy.
sourceOpts := srcOpts
sourceOpts.NoLock = true
sourceOpts.Transition = dstOpts.Transition
_, idx, err := z.getLatestObjectInfoWithIdx(ctx, srcBucket, srcObject, sourceOpts)
return copyObjectPlan{poolIdx: idx}, err
}
idx, err := z.getPoolIdxNoLock(ctx, dstBucket, dstObject, srcInfo.Size)
if err != nil {
return copyObjectPlan{}, err
}
plan := copyObjectPlan{poolIdx: idx}
if reference && !z.SinglePool() {
sourceOpts := srcOpts
sourceOpts.NoLock = true
_, sourcePool, err := z.getLatestObjectInfoWithIdx(ctx, srcBucket, srcObject, sourceOpts)
if err != nil {
return copyObjectPlan{}, err
}
plan.rewrite = sourcePool != idx
}
return plan, nil
}
func (z *erasureServerPools) CopyObject(ctx context.Context, srcBucket, srcObject, dstBucket, dstObject string, srcInfo ObjectInfo, srcOpts, dstOpts ObjectOptions) (objInfo ObjectInfo, err error) {
if err := checkCopyObjArgs(ctx, srcBucket, srcObject); err != nil {
return ObjectInfo{}, err
@@ -1692,6 +1762,15 @@ func (z *erasureServerPools) CopyObject(ctx context.Context, srcBucket, srcObjec
dstOpts.NoLock = true
}
if dstOpts.copyPlan == nil {
plan, err := z.prepareCopyObject(ctx, srcBucket, srcObject, dstBucket, dstObject, srcInfo, srcOpts, dstOpts)
if err != nil {
return ObjectInfo{}, err
}
dstOpts.copyPlan = &plan
}
poolIdx := dstOpts.copyPlan.poolIdx
if !z.SinglePool() && cpSrcDstSame && srcInfo.metadataOnly && dstOpts.ReplicaLockReconcile && srcOpts.VersionID == dstOpts.VersionID {
copies, err := z.metadataPoolInfos(ctx, dstBucket, dstObject, dstOpts)
if err != nil {
@@ -1700,7 +1779,8 @@ 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])
idx := copies[0].Index
dstOpts.replicaObjectInfo = z.replicaObjectInfo
idx := poolIdx
oi, err := z.serverPools[idx].CopyObject(ctx, srcBucket, srcObject, dstBucket, dstObject, srcInfo, srcOpts, dstOpts)
if err == nil {
err = z.retireReplicaCopies(ctx, dstBucket, dstObject, idx, oi)
@@ -1708,25 +1788,9 @@ func (z *erasureServerPools) CopyObject(ctx context.Context, srcBucket, srcObjec
return oi, err
}
var poolIdx int
if dstOpts.MetadataChg && cpSrcDstSame && srcInfo.metadataOnly && srcOpts.VersionID == dstOpts.VersionID {
// Restore status updates address an existing version. Keep ordinary
// copy/write allocation unchanged, including its latest-version lookup.
poolIdx, err = z.getPoolIdxExistingWithOpts(ctx, dstBucket, dstObject, ObjectOptions{
VersionID: dstOpts.VersionID,
MetadataChg: true,
NoLock: true,
Transition: dstOpts.Transition,
SkipDecommissioned: true,
SkipRebalancing: true,
})
} else {
poolIdx, err = z.getPoolIdxNoLock(ctx, dstBucket, dstObject, srcInfo.Size)
if !z.SinglePool() {
dstOpts.replicaContentGuard = z.guardReplicaContent
}
if err != nil {
return objInfo, err
}
if !z.SinglePool() && (dstOpts.ReplicaLockReconcile || !dstOpts.ReplicationRequest) {
dstOpts.replicaObjectInfo = z.replicaObjectInfo
if dstOpts.ReplicaLockReconcile && !dstOpts.DataMovement {
@@ -1738,9 +1802,7 @@ func (z *erasureServerPools) CopyObject(ctx context.Context, srcBucket, srcObjec
}
}
// CopyObjectHandler predicts the outcome of this decision in
// copyRewritesObjectData(); keep the two in sync.
if cpSrcDstSame && srcInfo.metadataOnly {
if cpSrcDstSame && !copyRewritesObjectData(srcInfo.metadataOnly && !srcInfo.Legacy, srcOpts, dstOpts) {
// Version ID is set for the destination and source == destination version ID.
if dstOpts.VersionID != "" && srcOpts.VersionID == dstOpts.VersionID {
return z.serverPools[poolIdx].CopyObject(ctx, srcBucket, srcObject, dstBucket, dstObject, srcInfo, srcOpts, dstOpts)
@@ -1762,6 +1824,10 @@ func (z *erasureServerPools) CopyObject(ctx context.Context, srcBucket, srcObjec
}
}
if srcInfo.PutObjReader == nil {
return ObjectInfo{}, errInvalidArgument
}
putOpts := ObjectOptions{
ServerSideEncryption: dstOpts.ServerSideEncryption,
UserDefined: srcInfo.UserDefined,
@@ -1771,12 +1837,15 @@ func (z *erasureServerPools) CopyObject(ctx context.Context, srcBucket, srcObjec
MTime: dstOpts.MTime,
NoLock: true,
EncryptFn: dstOpts.EncryptFn,
IndexCB: dstOpts.IndexCB,
WantChecksum: dstOpts.WantChecksum,
WantServerSideChecksumType: dstOpts.WantServerSideChecksumType,
PreserveETag: dstOpts.PreserveETag,
ReplicationRequest: dstOpts.ReplicationRequest,
ReplicaLockReconcile: dstOpts.ReplicaLockReconcile,
DataMovement: dstOpts.DataMovement,
replicaObjectInfo: dstOpts.replicaObjectInfo,
replicaContentGuard: dstOpts.replicaContentGuard,
}
return z.serverPools[poolIdx].PutObject(ctx, dstBucket, dstObject, srcInfo.PutObjReader, putOpts)
@@ -2437,21 +2506,21 @@ func (z *erasureServerPools) CompleteMultipartUpload(ctx context.Context, bucket
}
}()
// Several pools take the object lock first, then the upload lock below.
// A single pool takes the upload lock here and the set takes the object
// lock, in the object's own erasure set, only right before it commits.
if !z.SinglePool() {
if !opts.NoLock {
lk := z.NewObjectNSLock(bucket, encodeDirObject(object))
lkctx, err := lk.GetLock(ctx, globalOperationTimeout)
if err != nil {
return objInfo, err
}
ctx = lkctx.Context()
defer lk.Unlock(lkctx)
// Take the object lock before the upload lock for every pool count.
// UploadPartCopy holds its source read lock while it reads upload
// metadata, including when the source and destination are the same key.
if !opts.NoLock {
lk := z.NewObjectNSLock(bucket, encodeDirObject(object))
lkctx, err := lk.GetLock(ctx, globalOperationTimeout)
if err != nil {
return objInfo, err
}
opts.NoLock = true
ctx = lkctx.Context()
defer lk.Unlock(lkctx)
}
opts.NoLock = true
if !z.SinglePool() {
// A conditional completion must be evaluated against the logical
// latest object across pools, under the object write lock held for
// this operation. The pool hosting the upload may only hold a stale
@@ -2502,6 +2571,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 +3415,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
+7 -1
View File
@@ -844,7 +844,7 @@ func (s *erasureSets) CopyObject(ctx context.Context, srcBucket, srcObject, dstB
// Check if this request is only metadata update.
// CopyObjectHandler predicts the outcome of this decision in
// copyRewritesObjectData(); keep the two in sync.
if cpSrcDstSame && srcInfo.metadataOnly {
if cpSrcDstSame && srcInfo.metadataOnly && !srcInfo.Legacy {
// Version ID is set for the destination and source == destination version ID.
// perform an in-place update.
if dstOpts.VersionID != "" && srcOpts.VersionID == dstOpts.VersionID {
@@ -868,6 +868,10 @@ func (s *erasureSets) CopyObject(ctx context.Context, srcBucket, srcObject, dstB
}
}
if srcInfo.PutObjReader == nil {
return ObjectInfo{}, errInvalidArgument
}
putOpts := ObjectOptions{
ServerSideEncryption: dstOpts.ServerSideEncryption,
UserDefined: srcInfo.UserDefined,
@@ -879,10 +883,12 @@ func (s *erasureSets) CopyObject(ctx context.Context, srcBucket, srcObject, dstB
EncryptFn: dstOpts.EncryptFn,
WantChecksum: dstOpts.WantChecksum,
WantServerSideChecksumType: dstOpts.WantServerSideChecksumType,
PreserveETag: dstOpts.PreserveETag,
ReplicationRequest: dstOpts.ReplicationRequest,
ReplicaLockReconcile: dstOpts.ReplicaLockReconcile,
DataMovement: dstOpts.DataMovement,
replicaObjectInfo: dstOpts.replicaObjectInfo,
replicaContentGuard: dstOpts.replicaContentGuard,
}
return dstSet.putObject(ctx, dstBucket, dstObject, srcInfo.PutObjReader, putOpts)
+196
View File
@@ -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())
}
})
}
}
+226
View File
@@ -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)
}
}
+325
View File
@@ -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)
}
}
+33 -7
View File
@@ -4,7 +4,6 @@
package cmd
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
@@ -172,14 +171,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 {
@@ -275,7 +301,7 @@ func applyIAMReplicationItem(ctx context.Context, item iamReplicationItem) error
if len(item.Policy) == 0 {
return globalSiteReplicationSys.PeerAddPolicyHandler(ctx, item.Name, nil, item.UpdatedAt)
}
p, err := policy.ParseConfig(bytes.NewReader(item.Policy))
p, err := parsePolicySnapshotBytes(item.Policy)
if err != nil {
return err
}
+97 -6
View File
@@ -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, &current, 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, &current, 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(&current); 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 {
@@ -584,9 +676,8 @@ func (c *SiteReplicationSys) healIAMDeletions(ctx context.Context) (err error) {
}()
c.iamHealMu.Lock()
defer c.iamHealMu.Unlock()
c.RLock()
defer c.RUnlock()
if !c.enabled {
state, ok := c.stateSnapshot()
if !ok {
return nil
}
snapshot := globalIAMSys.store.revisionIndex().snapshot()
@@ -627,17 +718,17 @@ func (c *SiteReplicationSys) healIAMDeletions(ctx context.Context) (err error) {
c.iamRevisionProgress = make(map[string]iamRevisionProgress)
}
for id := range c.iamRevisionProgress {
if _, present := c.state.Peers[id]; !present {
if _, present := state.Peers[id]; !present {
delete(c.iamRevisionProgress, id)
}
}
var progressMu sync.Mutex
cerr := c.concDo(nil, func(id string, p madmin.PeerInfo) error {
cerr := state.concDo(nil, func(id string, p madmin.PeerInfo) error {
// Bound each pass, but retain acknowledgements independently of the
// pass deadline or unrelated changes at either site.
peerCtx, cancel := context.WithTimeout(ctx, defaultContextTimeout)
defer cancel()
client, err := c.getAdminClient(peerCtx, id)
client, err := state.getAdminClient(id)
if err != nil {
return err
}
+207 -152
View File
@@ -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
}
return auth.AccountOff
}(),
u, _, err := store.editStoredIdentity(ctx, cache, accessKey, regUser, func(stored *UserIdentity) error {
if stored.Credentials.IsTemp() || stored.Credentials.IsServiceAccount() {
return errIAMActionNotAllowed
}
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 {
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
// 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 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
}
if len(policyBuf) == 0 {
m.Delete(policy.SessionPolicyName)
m.Set(iamPolicyClaimNameSA(), inheritedPolicyType)
} 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)
return err
}
// 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,62 +3287,67 @@ 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.
if err != nil {
if errors.Is(err, errNoSuchUser) || errors.Is(err, errConfigNotFound) {
return nil
load := func() (any, error) {
loadedAt := time.Now()
newCache, err := newCachePopulate(ctx)
if err != nil {
return nil, err
}
return 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 {
cache.iamGroupPolicyMap.Store(k, v)
return true
})
maps.Copy(cache.iamGroupsMap, newCache.iamGroupsMap)
maps.Copy(cache.iamPolicyDocsMap, newCache.iamPolicyDocsMap)
maps.Copy(cache.iamUserGroupMemberships, newCache.iamUserGroupMemberships)
newCache.iamUserPolicyMap.Range(func(k string, v MappedPolicy) bool {
cache.iamUserPolicyMap.Store(k, v)
return true
})
maps.Copy(cache.iamUsersMap, newCache.iamUsersMap)
maps.Copy(cache.iamSTSAccountsMap, newCache.iamSTSAccountsMap)
newCache.iamSTSPolicyMap.Range(func(k string, v MappedPolicy) bool {
cache.iamSTSPolicyMap.Store(k, v)
return true
})
cache.updatedAt = time.Now()
return nil, nil
}
newCache, ok := val.(*iamCache)
if !ok {
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
}
cache := store.lock()
defer store.unlock()
// 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 {
cache.iamGroupPolicyMap.Store(k, v)
return true
})
maps.Copy(cache.iamGroupsMap, newCache.iamGroupsMap)
maps.Copy(cache.iamPolicyDocsMap, newCache.iamPolicyDocsMap)
maps.Copy(cache.iamUserGroupMemberships, newCache.iamUserGroupMemberships)
newCache.iamUserPolicyMap.Range(func(k string, v MappedPolicy) bool {
cache.iamUserPolicyMap.Store(k, v)
return true
})
maps.Copy(cache.iamUsersMap, newCache.iamUsersMap)
maps.Copy(cache.iamSTSAccountsMap, newCache.iamSTSAccountsMap)
newCache.iamSTSPolicyMap.Range(func(k string, v MappedPolicy) bool {
cache.iamSTSPolicyMap.Store(k, v)
return true
})
cache.updatedAt = time.Now()
return nil
return err
}
var errTokenSigningKeyUnavailable = errors.New("token signing key unavailable")
+301
View File
@@ -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")
}
})
}
+358
View File
@@ -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,578 @@
// 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))
}
}
// Healing must release its own configuration lock before calling the hook.
// Otherwise a waiting configuration writer blocks the hook's next read lock.
func TestSiteReplicationHealReadsStorageUnlocked(t *testing.T) {
const key = "heal-unlocked"
site := newSvcAccSite(t)
site.create(t, key)
stored := svcAccStored(t, site.groupRefreshFixture, key)
local := madmin.UserInfo{Status: madmin.AccountEnabled, UpdatedAt: stored.UpdatedAt}
remote := madmin.UserInfo{Status: madmin.AccountDisabled, UpdatedAt: stored.UpdatedAt.Add(-time.Minute)}
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}},
}}}
paused := &svcAccPausedStore{IAMStorageAPI: site.sys.store.IAMStorageAPI, loading: make(chan struct{}), proceed: make(chan struct{})}
site.sys.store.IAMStorageAPI = paused
release := sync.OnceFunc(func() { close(paused.proceed) })
defer release()
done := make(chan error, 1)
go func() {
done <- globalSiteReplicationSys.healUsers(site.ctx, site.obj, key, info)
}()
select {
case <-paused.loading:
case err := <-done:
t.Fatalf("heal did not read the account from storage (err=%v)", err)
case <-time.After(10 * time.Second):
t.Fatal("heal did not reach the storage read")
}
// Testing the writer lock without waiting keeps a regression from leaving
// deadlocked goroutines behind in the test process.
if globalSiteReplicationSys.TryLock() {
globalSiteReplicationSys.Unlock() //nolint:staticcheck // only the lock is of interest
} else {
t.Error("heal holds the replication configuration lock during a storage read")
}
release()
select {
case err := <-done:
mustIAM(t, err)
case <-time.After(10 * time.Second):
t.Fatal("heal did not return")
}
if got := site.items(key); len(got) != 2 {
t.Errorf("the site sent %d items, want the create and the healed snapshot", len(got))
}
}
+138 -55
View File
@@ -1087,17 +1087,52 @@ func (sys *IAMSys) notifyForServiceAccount(ctx context.Context, accessKey string
}
type newServiceAccountOpts struct {
sessionPolicy *policy.Policy
accessKey string
secretKey string
status string // Used by replication snapshots; local creates default to enabled.
name, description string
expiration *time.Time
allowSiteReplicatorAccount bool // allow creating internal service account for site-replication.
sessionPolicy *policy.Policy
accessKey string
secretKey string
status string // Used by replication snapshots; local creates default to enabled.
name, description string
expiration *time.Time
allowSiteReplicatorAccount bool // allow creating internal service account for site-replication.
preserveEmptyEmbeddedPolicy bool // preserve historical deny-all empty embedded policies on snapshot import/replication.
claims map[string]any
}
func svcAcctClaimsHaveEmbeddedPolicy(claims map[string]any) bool {
if claims == nil {
return false
}
pt, _ := claims[iamPolicyClaimNameSA()].(string)
return pt == embeddedPolicyType
}
// 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,19 +1150,9 @@ 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()
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
}
policyBuf, err := marshalServiceAccountPolicy(opts.sessionPolicy)
if err != nil {
return auth.Credentials{}, time.Time{}, err
}
// found newly requested service account, to be same as
@@ -1141,15 +1166,45 @@ func (sys *IAMSys) NewServiceAccount(ctx context.Context, parentUser string, gro
m := make(map[string]any)
m[parentClaim] = parentUser
if len(policyBuf) > 0 {
switch {
case len(policyBuf) > 0:
m[policy.SessionPolicyName] = base64.StdEncoding.EncodeToString(policyBuf)
m[iamPolicyClaimNameSA()] = embeddedPolicyType
} else {
case opts.preserveEmptyEmbeddedPolicy:
switch {
case opts.sessionPolicy != nil:
p := *opts.sessionPolicy
if p.Version == "" {
p.Version = policy.DefaultVersion
}
buf, merr := json.Marshal(p)
if merr != nil {
return auth.Credentials{}, time.Time{}, merr
}
m[policy.SessionPolicyName] = base64.StdEncoding.EncodeToString(buf)
m[iamPolicyClaimNameSA()] = embeddedPolicyType
default:
if raw, ok := opts.claims[policy.SessionPolicyName].(string); ok && raw != "" {
m[policy.SessionPolicyName] = raw
m[iamPolicyClaimNameSA()] = embeddedPolicyType
} else {
m[iamPolicyClaimNameSA()] = inheritedPolicyType
}
}
default:
m[iamPolicyClaimNameSA()] = inheritedPolicyType
}
// 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 +1217,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 +1265,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 +1365,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 {
policyBytes, err := base64.StdEncoding.DecodeString(sp)
if err != nil {
return UserIdentity{}, nil, err
}
embeddedPolicy, err = policy.ParseConfig(bytes.NewReader(policyBytes))
if err != nil {
return UserIdentity{}, nil, err
if !ptok || !spok || pt != embeddedPolicyType {
return nil, nil
}
policyBytes, err := base64.StdEncoding.DecodeString(sp)
if err != nil {
return nil, err
}
return policy.ParseConfig(bytes.NewReader(policyBytes))
}
// serviceAccountSnapshot returns service account u, a record loaded from
// storage, as the create that replication, the first sync, heal and export
// carry. Its claims and its embedded policy are read from the token of that
// record, so that every part of the snapshot is of one revision: a server's
// cache may hold the account as it was before an edit made through another
// server.
func serviceAccountSnapshot(u UserIdentity) (madmin.SRSvcAccCreate, error) {
jwtClaims, err := extractJWTClaims(u)
if err != nil {
return madmin.SRSvcAccCreate{}, err
}
// 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
}
}
return sa, embeddedPolicy, nil
cr := u.Credentials
return madmin.SRSvcAccCreate{
Parent: cr.ParentUser,
AccessKey: cr.AccessKey,
SecretKey: cr.SecretKey,
Groups: cr.Groups,
Claims: jwtClaims.Map(),
SessionPolicy: policyJSON,
Status: cr.Status,
Name: cr.Name,
Description: cr.Description,
Expiration: &cr.Expiration,
}, nil
}
// GetTemporaryAccount - wrapper method to get information about a temporary account
@@ -1395,25 +1495,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 +1739,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)
}
+78
View File
@@ -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
}
+153
View File
@@ -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)
}
}
+4 -4
View File
@@ -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)
}
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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 {
+5
View File
@@ -122,6 +122,10 @@ 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
// Selected under the object locks, before copy encryption/compression.
copyPlan *copyObjectPlan
// RefuseLockedOverwrite refuses, under the destination's write lock, a
// write that would replace a stored version Object Lock protects: the
@@ -297,6 +301,7 @@ type ObjectLayer interface {
NewObjectNSLock(bucket, object string) RWLocker
// lockObjects locks several objects together, see erasureServerPools.
lockObjects(ctx context.Context, reqs ...objectLockRequest) (LockContext, func(), error)
prepareCopyObject(ctx context.Context, srcBucket, srcObject, dstBucket, dstObject string, srcInfo ObjectInfo, srcOpts, dstOpts ObjectOptions) (copyObjectPlan, error)
// Storage operations.
Shutdown(context.Context) error
+7 -2
View File
@@ -303,13 +303,18 @@ func delOpts(ctx context.Context, r *http.Request, bucket, object string) (opts
}
}
vc, _, err := checkBucketDeleteMetadata(bucket)
if err != nil {
return opts, err
}
opts.DeletePrefix = deletePrefix
opts.Versioned = globalBucketVersioningSys.PrefixEnabled(bucket, object)
opts.Versioned = vc.PrefixEnabled(object)
// Objects matching prefixes should not leave delete markers,
// dramatically reduces namespace pollution while keeping the
// benefits of replication, make sure to apply version suspension
// only at bucket level instead.
opts.VersionSuspended = globalBucketVersioningSys.Suspended(bucket)
opts.VersionSuspended = vc.Suspended()
// For directory objects, delete `null` version permanently.
if isDirObject(object) && opts.VersionID == "" {
opts.VersionID = nullVersionID
+170
View File
@@ -0,0 +1,170 @@
// Copyright (c) 2026 PGSTY
// SPDX-License-Identifier: AGPL-3.0-or-later
package cmd
import (
"archive/zip"
"bytes"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/minio/minio/internal/auth"
xhttp "github.com/minio/minio/internal/http"
)
func TestAPIAttributesAndArchiveStoredTags(t *testing.T) {
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
t: t, makeBucketOptions: MakeBucketOptions{VersioningEnabled: true},
objAPITest: func(obj ObjectLayer, backend, bucket string, router http.Handler, root auth.Credentials, t *testing.T) {
_, err := globalBucketMetadataSys.Update(t.Context(), bucket, bucketVersioningConfig, enabledBucketVersioningConfig)
if err != nil {
t.Fatal(err)
}
var archive bytes.Buffer
zw := zip.NewWriter(&archive)
entry, err := zw.Create("entry.txt")
if err != nil {
t.Fatal(err)
}
if _, err = entry.Write([]byte("private entry")); err != nil {
t.Fatal(err)
}
if err = zw.Close(); err != nil {
t.Fatal(err)
}
for _, indexed := range []bool{false, true} {
key := fmt.Sprintf("tagged-%v.zip", indexed)
put := func(tag string) ObjectInfo {
oi, err := obj.PutObject(t.Context(), bucket, key, mustGetPutObjReader(t, bytes.NewReader(archive.Bytes()), int64(archive.Len()), "", ""), ObjectOptions{Versioned: true, UserDefined: map[string]string{xhttp.AmzObjectTagging: "tier=" + tag}})
if err != nil {
t.Fatal(err)
}
return oi
}
old := put("public")
latest := put("restricted")
if indexed {
if _, err := updateObjectMetadataWithZipInfo(t.Context(), obj, bucket, key, ObjectOptions{VersionID: latest.VersionID}); err != nil {
t.Fatal(err)
}
}
for _, anonymous := range []bool{false, true} {
for _, effect := range []string{"Allow", "Deny"} {
actions := `"s3:GetObject","s3:GetObjectAttributes","s3:GetObjectVersion","s3:GetObjectVersionAttributes"`
condition := `"Condition":{"StringEquals":{"s3:ExistingObjectTag/tier":"restricted"}}`
statement := fmt.Sprintf(`{"Effect":"%s","Action":[%s],"Resource":["arn:aws:s3:::%s/*"],%s}`, effect, actions, bucket, condition)
if effect == "Deny" {
statement += fmt.Sprintf(`,{"Effect":"Allow","Action":[%s],"Resource":["arn:aws:s3:::%s/*"]}`, actions, bucket)
}
statement += fmt.Sprintf(`,{"Effect":"Allow","Action":["s3:ListBucket"],"Resource":["arn:aws:s3:::%s"]}`, bucket)
creds := newDeleteAuthzPolicyUser(t, backend, bucket, "["+statement+"]")
if anonymous {
document := `{"Version":"2012-10-17","Statement":[` + strings.ReplaceAll(statement, `"Effect":`, `"Principal":"*","Effect":`) + `]}`
if _, err := globalBucketMetadataSys.Update(t.Context(), bucket, bucketPolicyConfig, []byte(document)); err != nil {
t.Fatal(err)
}
}
for _, operation := range []string{"attributes", "version-attributes", http.MethodGet, http.MethodHead, "LIST"} {
t.Run(fmt.Sprintf("%s/indexed=%v/anonymous=%v/%s/%s", backend, indexed, anonymous, effect, operation), func(t *testing.T) {
verb := http.MethodGet
target := getGetObjectURL("", bucket, key+"/entry.txt")
headers := map[string]string{xMinIOExtract: "true", xhttp.AmzObjectTagging: "tier=public"}
want := http.StatusOK
if effect == "Deny" {
want = http.StatusForbidden
}
switch operation {
case "attributes", "version-attributes":
q := url.Values{"attributes": {""}}
if operation == "version-attributes" {
q.Set("versionId", old.VersionID)
if effect == "Allow" {
want = http.StatusForbidden
} else {
want = http.StatusOK
}
}
target = makeTestTargetURL("", bucket, key, q)
headers[xhttp.AmzObjectAttributes] = "ETag,ObjectSize"
case http.MethodHead:
verb = http.MethodHead
case "LIST":
target = "/" + bucket + "?list-type=2&prefix=" + key + "/"
}
req, err := newTestSignedRequestV4(verb, target, 0, nil, creds.AccessKey, creds.SecretKey, headers)
if err != nil {
t.Fatal(err)
}
if anonymous {
req.Header.Del("Authorization")
}
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != want {
t.Fatalf("status %d, want %d: %s", rec.Code, want, rec.Body.String())
}
if rec.Code == http.StatusOK && operation == http.MethodGet && rec.Body.String() != "private entry" {
t.Fatalf("wrong extracted content: %s", rec.Body.String())
}
if rec.Code == http.StatusForbidden && strings.Contains(rec.Body.String(), "private entry") {
t.Fatal("denied response disclosed content")
}
})
}
}
}
}
// Each Attributes action is necessary; a conditional Deny on either
// action must win even when the other action is unconditionally allowed.
for _, versioned := range []bool{false, true} {
for _, deniedAction := range []string{"GetObject", "GetObjectAttributes"} {
actions := `"s3:GetObject","s3:GetObjectAttributes"`
if versioned {
actions = `"s3:GetObjectVersion","s3:GetObjectVersionAttributes"`
deniedAction = strings.Replace(deniedAction, "GetObject", "GetObjectVersion", 1)
}
statements := fmt.Sprintf(`[{"Effect":"Allow","Action":[%s],"Resource":["arn:aws:s3:::%s/*"]},{"Effect":"Deny","Action":["s3:%s"],"Resource":["arn:aws:s3:::%s/*"],"Condition":{"StringEquals":{"s3:ExistingObjectTag/tier":"restricted"}}}]`, actions, bucket, deniedAction, bucket)
creds := newDeleteAuthzPolicyUser(t, backend, bucket, statements)
q := url.Values{"attributes": {""}}
if versioned {
oi, err := obj.GetObjectInfo(t.Context(), bucket, "tagged-true.zip", ObjectOptions{})
if err != nil {
t.Fatal(err)
}
q.Set("versionId", oi.VersionID)
}
req, err := newTestSignedRequestV4(http.MethodGet, makeTestTargetURL("", bucket, "tagged-true.zip", q), 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 != http.StatusForbidden {
t.Fatalf("%s Deny: %d %s", deniedAction, rec.Code, rec.Body.String())
}
}
}
listOnly := newDeleteAuthzPolicyUser(t, backend, bucket, fmt.Sprintf(`[{"Effect":"Allow","Action":["s3:ListBucket"],"Resource":["arn:aws:s3:::%s"]}]`, bucket))
for _, extract := range []bool{false, true} {
req, err := newTestSignedRequestV4(http.MethodGet, "/"+bucket+"?list-type=2&prefix=tagged-true.zip/", 0, nil, listOnly.AccessKey, listOnly.SecretKey, map[string]string{xMinIOExtract: fmt.Sprint(extract)})
if err != nil {
t.Fatal(err)
}
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
want := http.StatusOK
if extract {
want = http.StatusForbidden
}
if rec.Code != want {
t.Fatalf("list-only extract=%v: %d %s", extract, rec.Code, rec.Body.String())
}
}
},
})
}
+99
View File
@@ -0,0 +1,99 @@
// 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{UserDefined: map[string]string{xhttp.AmzObjectTagging: "tier=restricted"}}); 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+`"]}]`)
tagged := newDeleteAuthzPolicyUser(t, "", bucket, `[{"Effect":"Allow","Action":["s3:GetObject","s3:GetObjectAttributes"],"Resource":["arn:aws:s3:::`+bucket+`/*"],"Condition":{"StringEquals":{"s3:ExistingObjectTag/tier":"restricted"}}}]`)
unauthorized := newDeleteAuthzPolicyUser(t, "", bucket, `[{"Effect":"Allow","Action":["s3:GetObjectAttributes"],"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)
check("healthy", "tagged", tagged, object, http.StatusOK)
check("healthy", "unauthorized", unauthorized, object, http.StatusForbidden)
defer objectReadFault(z, bucket, object, 10)()
check("read quorum lost", "withoutList", withoutList, object, http.StatusServiceUnavailable)
check("read quorum lost", "withList", withList, object, http.StatusServiceUnavailable)
check("read quorum lost", "tagged", tagged, object, http.StatusServiceUnavailable)
check("read quorum lost", "unauthorized", unauthorized, object, http.StatusForbidden)
}
+408
View File
@@ -0,0 +1,408 @@
// Copyright (c) 2026 PGSTY
// SPDX-License-Identifier: AGPL-3.0-or-later
package cmd
import (
"bytes"
"context"
"crypto/md5"
"encoding/base64"
"fmt"
"maps"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/minio/minio/internal/hash"
xhttp "github.com/minio/minio/internal/http"
"github.com/minio/minio/internal/kms"
)
// Place uploaded versions in specific pools, while retaining the real HTTP
// encryption/compression pipeline and the normal pools-layer copy operation.
type copyUploadPool struct {
*erasureServerPools
uploadPool int
}
func (z *copyUploadPool) PutObject(ctx context.Context, bucket, object string, r *PutObjReader, opts ObjectOptions) (ObjectInfo, error) {
return z.serverPools[z.uploadPool].PutObject(ctx, bucket, object, r, opts)
}
func TestAPICopyHistoricalVersionAcrossPools(t *testing.T) {
z, _ := consistencyPools(t)
placed := &copyUploadPool{erasureServerPools: z}
bucket, router, err := initAPIHandlerTest(t.Context(), placed, nil, MakeBucketOptions{VersioningEnabled: true})
if err != nil {
t.Fatal(err)
}
setObjectLayer(placed)
if _, err = globalBucketMetadataSys.Update(t.Context(), bucket, bucketVersioningConfig, enabledBucketVersioningConfig); err != nil {
t.Fatal(err)
}
previousKMS, previousTLS := GlobalKMS, globalIsTLS
GlobalKMS, err = kms.NewBuiltin("copy-key", bytes.Repeat([]byte{0x33}, 32))
if err != nil {
t.Fatal(err)
}
globalIsTLS = true
defer func() { GlobalKMS, globalIsTLS = previousKMS, previousTLS }()
customer := func(value byte) map[string]string {
key := bytes.Repeat([]byte{value}, 32)
sum := md5.Sum(key)
return map[string]string{xhttp.AmzServerSideEncryptionCustomerAlgorithm: xhttp.AmzEncryptionAES, xhttp.AmzServerSideEncryptionCustomerKey: base64.StdEncoding.EncodeToString(key), xhttp.AmzServerSideEncryptionCustomerKeyMD5: base64.StdEncoding.EncodeToString(sum[:])}
}
for _, tc := range []struct{ mode, movement string }{
{"plain", "active"},
{"compressed", "active"},
{"sse-s3", "active"},
{"sse-c", "active"},
{"sse-kms", "active"},
{"sse-c", "decommission"},
{"sse-kms", "rebalance"},
} {
mode := tc.mode
for _, sourcePool := range []int{0, 1} {
t.Run(fmt.Sprintf("%s/%s/source=%d", mode, tc.movement, sourcePool), func(t *testing.T) {
compressed := mode == "compressed"
restore := setCopyChecksumCompression(true)
globalCompressConfigMu.Lock()
globalCompressConfig.Enabled = compressed
globalCompressConfigMu.Unlock()
defer restore()
sourceHeaders := map[string]string{xhttp.AmzChecksumCRC32: ""}
if mode == "sse-s3" {
sourceHeaders[xhttp.AmzServerSideEncryption] = xhttp.AmzEncryptionAES
}
if mode == "sse-kms" {
sourceHeaders[xhttp.AmzServerSideEncryption] = xhttp.AmzEncryptionKMS
sourceHeaders[xhttp.AmzServerSideEncryptionKmsID] = "copy-key"
sourceHeaders[xhttp.AmzServerSideEncryptionKmsContext] = base64.StdEncoding.EncodeToString([]byte(`{"tenant":"source"}`))
}
if mode == "sse-c" {
maps.Copy(sourceHeaders, customer(0x11))
}
pattern := []byte("historical encrypted compressed content-")
repeats := 8192
if mode == "plain" {
repeats = compMinIndexSize/len(pattern) + 1
}
data := bytes.Repeat(pattern, repeats)
sourceHeaders[xhttp.AmzChecksumCRC32] = mustChecksum(t, hash.ChecksumCRC32, data)
key := fmt.Sprintf("copy-%s-%s-%d.txt", mode, tc.movement, sourcePool)
placed.uploadPool = sourcePool
putCopyChecksumSource(t, router, globalActiveCred, bucket, key, data, sourceHeaders)
original, err := z.GetObjectInfo(t.Context(), bucket, key, ObjectOptions{})
if err != nil {
t.Fatal(err)
}
if original.VersionID == "" {
t.Fatal("fixture has no version ID")
}
if _, err := z.serverPools[sourcePool].GetObjectInfo(t.Context(), bucket, key, ObjectOptions{VersionID: original.VersionID}); err != nil {
t.Fatal(err)
}
if original.IsCompressed() != compressed {
t.Fatalf("fixture compressed=%v", original.IsCompressed())
}
placed.uploadPool = 1 - sourcePool
putCopyChecksumSource(t, router, globalActiveCred, bucket, key, []byte("newer version"), nil)
newer, err := z.GetObjectInfo(t.Context(), bucket, key, ObjectOptions{})
if err != nil {
t.Fatal(err)
}
setCopyMovingPool(t, z, sourcePool, tc.movement)
// Toggle compression so metadata has to describe the bytes written now.
restore()
restoreTarget := setCopyChecksumCompression(true)
globalCompressConfigMu.Lock()
globalCompressConfig.Enabled = !compressed
globalCompressConfigMu.Unlock()
defer restoreTarget()
headers := map[string]string{xhttp.AmzMetadataDirective: "REPLACE"}
getHeaders := map[string]string{}
if mode == "sse-s3" {
headers[xhttp.AmzServerSideEncryption] = xhttp.AmzEncryptionAES
}
if mode == "sse-kms" {
headers[xhttp.AmzServerSideEncryption] = xhttp.AmzEncryptionKMS
headers[xhttp.AmzServerSideEncryptionKmsID] = "copy-key"
headers[xhttp.AmzServerSideEncryptionKmsContext] = base64.StdEncoding.EncodeToString([]byte(`{"tenant":"destination"}`))
}
if mode == "sse-c" {
maps.Copy(headers, customer(0x22))
maps.Copy(getHeaders, customer(0x22))
old := customer(0x11)
headers[xhttp.AmzServerSideEncryptionCopyCustomerAlgorithm] = old[xhttp.AmzServerSideEncryptionCustomerAlgorithm]
headers[xhttp.AmzServerSideEncryptionCopyCustomerKey] = old[xhttp.AmzServerSideEncryptionCustomerKey]
headers[xhttp.AmzServerSideEncryptionCopyCustomerKeyMD5] = old[xhttp.AmzServerSideEncryptionCustomerKeyMD5]
}
rec := copyChecksumRequest(t, router, globalActiveCred, bucket, key+"?versionId="+original.VersionID, key, headers)
if rec.Code != http.StatusOK {
t.Fatalf("copy: %d %s", rec.Code, rec.Body.String())
}
copied, err := z.GetObjectInfo(t.Context(), bucket, key, ObjectOptions{})
if err != nil || copied.VersionID == "" || copied.VersionID == original.VersionID || copied.VersionID == newer.VersionID {
t.Fatalf("copy did not create a version: %+v %v", copied, err)
}
if copied.IsCompressed() != (!compressed && mode != "sse-c") {
t.Fatalf("copy did not use destination compression setting: %v", copied.IsCompressed())
}
if copied.IsCompressed() && len(data) > compMinIndexSize {
if len(copied.Parts) == 0 || len(copied.Parts[0].Index) == 0 {
t.Fatalf("compressed multi-block copy lost seek index: %+v", copied.Parts)
}
rangeHeaders := maps.Clone(getHeaders)
rangeHeaders["Range"] = "bytes=6000000-6000127"
req, err := newTestSignedRequestV4(http.MethodGet, getGetObjectURL("", bucket, key)+"?versionId="+copied.VersionID, 0, nil, globalActiveCred.AccessKey, globalActiveCred.SecretKey, rangeHeaders)
if err != nil {
t.Fatal(err)
}
rrec := httptest.NewRecorder()
router.ServeHTTP(rrec, req)
if rrec.Code != http.StatusPartialContent || !bytes.Equal(rrec.Body.Bytes(), data[6000000:6000128]) {
t.Fatalf("ranged GET on compressed copy: %d len=%d", rrec.Code, rrec.Body.Len())
}
}
if mode == "sse-kms" {
assertFederationKMSContext(t, copied, headers[xhttp.AmzServerSideEncryptionKmsContext])
assertFederationKMSContext(t, original, sourceHeaders[xhttp.AmzServerSideEncryptionKmsContext])
}
if _, err = z.serverPools[1-sourcePool].GetObjectInfo(t.Context(), bucket, key, ObjectOptions{VersionID: copied.VersionID}); err != nil {
t.Fatalf("copy was not placed with destination: %v", err)
}
read := func(version string, headers map[string]string, want []byte) {
t.Helper()
req, err := newTestSignedRequestV4(http.MethodGet, getGetObjectURL("", bucket, key)+"?versionId="+version, 0, nil, globalActiveCred.AccessKey, globalActiveCred.SecretKey, headers)
if err != nil {
t.Fatal(err)
}
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK || !bytes.Equal(rec.Body.Bytes(), want) {
t.Fatalf("GET version %s: %d body length=%d, want %d", version, rec.Code, rec.Body.Len(), len(want))
}
}
read(copied.VersionID, getHeaders, data)
oldHeaders := map[string]string{}
if mode == "sse-c" {
oldHeaders = customer(0x11)
}
read(original.VersionID, oldHeaders, data)
read(newer.VersionID, nil, []byte("newer version"))
})
}
}
}
// Internal metadata updates do not all set MetadataChg. They must still
// address the requested historical version, and must never create a new one.
func TestPoolsCopyExactVersionMetadata(t *testing.T) {
z, bucket := consistencyPools(t)
for sourcePool := range 2 {
key := fmt.Sprintf("exact-%d", sourcePool)
older := putConsistencyObject(t, z, bucket, key, sourcePool, "old data", ObjectOptions{Versioned: true, MTime: UTCNow().Add(-time.Hour)})
newer := putConsistencyObject(t, z, bucket, key, 1-sourcePool, "new data", ObjectOptions{Versioned: true, MTime: UTCNow()})
gr, err := z.GetObjectNInfo(t.Context(), bucket, key, nil, nil, ObjectOptions{VersionID: older.VersionID, NoLock: true})
if err != nil {
t.Fatal(err)
}
info := gr.ObjInfo
info.Reader, err = hash.NewReader(t.Context(), gr, info.Size, "", "", info.Size)
if err != nil {
gr.Close()
t.Fatal(err)
}
info.PutObjReader = NewPutObjReader(info.Reader)
info.metadataOnly = true
info.UserDefined["updated"] = "yes"
_, err = z.CopyObject(t.Context(), bucket, key, bucket, key, info, ObjectOptions{VersionID: older.VersionID}, ObjectOptions{Versioned: true, VersionID: older.VersionID, MTime: older.ModTime})
gr.Close()
if err != nil {
t.Fatal(err)
}
got, err := z.serverPools[sourcePool].GetObjectInfo(t.Context(), bucket, key, ObjectOptions{VersionID: older.VersionID})
if err != nil || got.UserDefined["updated"] != "yes" {
t.Fatalf("historical metadata not updated: %v %v", got.UserDefined, err)
}
got, err = z.GetObjectInfo(t.Context(), bucket, key, ObjectOptions{})
if err != nil || got.VersionID != newer.VersionID || got.UserDefined["updated"] != "" {
t.Fatalf("latest changed: %+v %v", got, err)
}
if got := readCopyChecksumObject(t, z, bucket, key, ObjectOptions{VersionID: older.VersionID}); string(got) != "old data" {
t.Fatalf("historical body changed: %q", got)
}
}
}
func setCopyMovingPool(t *testing.T, z *erasureServerPools, pool int, movement string) {
t.Helper()
switch movement {
case "decommission":
z.poolMetaMutex.Lock()
z.poolMeta.Pools[pool].Decommission = &PoolDecommissionInfo{}
z.poolMetaMutex.Unlock()
t.Cleanup(func() {
z.poolMetaMutex.Lock()
z.poolMeta.Pools[pool].Decommission = nil
z.poolMetaMutex.Unlock()
})
case "rebalance":
z.rebalMu.Lock()
z.rebalMeta = &rebalanceMeta{PoolStats: []*rebalanceStats{{}, {}}}
z.rebalMeta.PoolStats[pool] = &rebalanceStats{Participating: true, Info: rebalanceInfo{Status: rebalStarted}}
z.rebalMu.Unlock()
t.Cleanup(func() { z.rebalMu.Lock(); z.rebalMeta = nil; z.rebalMu.Unlock() })
}
}
// The content that a metadata-only replica preserves must be the same content
// that reads selected, even when write allocation would skip its pool.
func TestCopyReplicaMetadataMovingPool(t *testing.T) {
z, _ := consistencyPools(t)
bucket, router, err := initAPIHandlerTest(t.Context(), z, nil, MakeBucketOptions{LockEnabled: true})
mustIAM(t, err)
setObjectLayer(z)
_, err = globalBucketMetadataSys.Update(t.Context(), bucket, bucketVersioningConfig, enabledBucketVersioningConfig)
mustIAM(t, err)
const body = "current-body"
for _, api := range []bool{false, true} {
for sourcePool := range 2 {
for _, movement := range []string{"decommission", "rebalance"} {
for _, kind := range []string{"uuid", "null"} {
for _, duplicate := range []string{"absent", "older", "tie"} {
// The lower pool index wins equal ModTimes, just as on reads.
if duplicate == "tie" && sourcePool != 0 {
continue
}
t.Run(fmt.Sprintf("api=%t/source=%d/%s/%s/%s", api, sourcePool, movement, kind, duplicate), func(t *testing.T) {
key := fmt.Sprintf("metadata-%t-%d-%s-%s-%s", api, sourcePool, movement, kind, duplicate)
oi := putConsistencyObject(t, z, bucket, key, sourcePool, body, ObjectOptions{
Versioned: kind == "uuid", MTime: UTCNow().Add(-time.Minute),
WantChecksum: hash.NewChecksumFromData(hash.ChecksumCRC32, []byte(body)),
})
version := oi.VersionID
if version == "" {
version = nullVersionID
}
if duplicate != "absent" {
stamp := oi.ModTime
if duplicate == "older" {
stamp = stamp.Add(-time.Hour)
}
putConsistencyObject(t, z, bucket, key, 1-sourcePool, "stale---body", ObjectOptions{
Versioned: kind == "uuid", VersionID: version, MTime: stamp,
UserDefined: poolLockMetadata("", "ON", "", objectLockTestStamp1000),
})
}
if got := readCopyChecksumObject(t, z, bucket, key, ObjectOptions{VersionID: version}); string(got) != body {
t.Fatalf("fixture: %q", got)
}
setCopyMovingPool(t, z, sourcePool, movement)
if api {
headers := map[string]string{
xhttp.AmzCopySource: "/" + bucket + "/" + key + "?versionId=" + version,
xhttp.AmzMetadataDirective: "REPLACE", "X-Amz-Meta-Updated": "yes",
xhttp.MinIOSourceReplicationRequest: "true", xhttp.AmzBucketReplicationStatus: "REPLICA",
}
rec := r5Request(t, router, globalActiveCred, http.MethodPut, "/"+bucket+"/"+key+"?versionId="+version, "", headers)
if rec.Code != http.StatusOK {
t.Fatalf("metadata copy: %d %s", rec.Code, rec.Body.String())
}
assertCopyChecksumResponse(t, rec, hash.ChecksumCRC32, []byte(body))
} else {
oi.metadataOnly = true
oi.UserDefined["X-Amz-Meta-Updated"] = "yes"
_, err = z.CopyObject(t.Context(), bucket, key, bucket, key, oi,
ObjectOptions{VersionID: version}, ObjectOptions{Versioned: kind == "uuid", VersionID: version, MTime: oi.ModTime, ReplicaLockReconcile: true})
mustIAM(t, err)
}
if got := readCopyChecksumObject(t, z, bucket, key, ObjectOptions{VersionID: version}); string(got) != body {
t.Fatalf("metadata copy replaced content: %q", got)
}
stored, err := z.serverPools[sourcePool].GetObjectInfo(t.Context(), bucket, key, ObjectOptions{VersionID: version})
mustIAM(t, err)
if stored.UserDefined["X-Amz-Meta-Updated"] != "yes" || !bytes.Equal(stored.Checksum, oi.Checksum) {
t.Fatalf("metadata/checksum: %v, %x; want checksum %x", stored.UserDefined, stored.Checksum, oi.Checksum)
}
if duplicate != "absent" && storedObjectLockState(stored.UserDefined).legalHold != "ON" {
t.Fatal("lost protection from the retired copy")
}
_, err = z.serverPools[1-sourcePool].GetObjectInfo(t.Context(), bucket, key, ObjectOptions{VersionID: version})
if !isErrObjectNotFound(err) && !isErrVersionNotFound(err) {
t.Fatalf("stale copy not retired: %v", err)
}
})
}
}
}
}
}
}
func TestAPICopyRewriteMovingPool(t *testing.T) {
z, _ := consistencyPools(t)
placed := &copyUploadPool{erasureServerPools: z}
bucket, router, err := initAPIHandlerTest(t.Context(), placed, nil, MakeBucketOptions{})
mustIAM(t, err)
setObjectLayer(placed)
previousKMS := GlobalKMS
GlobalKMS, err = kms.NewBuiltin("copy-key", bytes.Repeat([]byte{0x33}, 32))
mustIAM(t, err)
defer func() { GlobalKMS = previousKMS }()
for sourcePool := range 2 {
for _, movement := range []string{"decommission", "rebalance"} {
for _, mode := range []string{"sse-s3", "checksum", "default-checksum", "invalid-self-copy"} {
t.Run(fmt.Sprintf("source=%d/%s/%s", sourcePool, movement, mode), func(t *testing.T) {
key := fmt.Sprintf("rewrite-%d-%s-%s", sourcePool, movement, mode)
data := []byte("rewriting a source on a draining pool")
sourceHeaders := map[string]string{}
if mode != "default-checksum" {
sourceHeaders[xhttp.AmzChecksumCRC32] = mustChecksum(t, hash.ChecksumCRC32, data)
}
placed.uploadPool = sourcePool
putCopyChecksumSource(t, router, globalActiveCred, bucket, key, data, sourceHeaders)
setCopyMovingPool(t, z, sourcePool, movement)
headers := map[string]string{xhttp.AmzMetadataDirective: "REPLACE"}
wantChecksum := hash.ChecksumCRC32
switch mode {
case "sse-s3":
headers[xhttp.AmzServerSideEncryption] = xhttp.AmzEncryptionAES
case "checksum", "invalid-self-copy":
headers[xhttp.AmzChecksumAlgo] = "SHA256"
wantChecksum = hash.ChecksumSHA256
if mode == "invalid-self-copy" {
delete(headers, xhttp.AmzMetadataDirective)
}
case "default-checksum":
wantChecksum = hash.ChecksumCRC64NVME
}
rec := copyChecksumRequest(t, router, globalActiveCred, bucket, key, key, headers)
if mode == "invalid-self-copy" {
if rec.Code != http.StatusBadRequest {
t.Fatalf("invalid self-copy: %d %s", rec.Code, rec.Body.String())
}
_, err := z.serverPools[1-sourcePool].GetObjectInfo(t.Context(), bucket, key, ObjectOptions{})
if !isErrObjectNotFound(err) {
t.Fatalf("rejected copy wrote a destination: %v", err)
}
return
}
if rec.Code != http.StatusOK {
t.Fatalf("rewrite: %d %s", rec.Code, rec.Body.String())
}
assertCopyChecksumResponse(t, rec, wantChecksum, data)
if got := readCopyChecksumObject(t, z, bucket, key, ObjectOptions{}); !bytes.Equal(got, data) {
t.Fatalf("rewritten body: %q", got)
}
if _, err := z.serverPools[1-sourcePool].GetObjectInfo(t.Context(), bucket, key, ObjectOptions{}); err != nil {
t.Fatalf("rewrite did not reach active pool: %v", err)
}
assertCopyChecksum(t, z, bucket, key, wantChecksum, data, false, nil)
})
}
}
}
}
+6 -13
View File
@@ -35,22 +35,15 @@ import (
// the single-disk and 16-disk fixtures are single-pool backends.
func TestAPICopyObjectReplicaTaggingTimestampUnderKMS(t *testing.T) {
defer DetectTestLeak(t)()
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{t: t, objAPITest: testAPICopyObjectReplicaTaggingTimestampUnderKMS})
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
t: t, objAPITest: testAPICopyObjectReplicaTaggingTimestampUnderKMS,
// Ignore host free-space percentage while retaining real disk I/O.
// Install once before IAM starts and keep the disks until shutdown.
wrapDisk: func(disk StorageAPI) StorageAPI { return tagTestCapacityDisk{StorageAPI: disk} },
})
}
func testAPICopyObjectReplicaTaggingTimestampUnderKMS(obj ObjectLayer, instance, bucket string, router http.Handler, creds auth.Credentials, t *testing.T) {
// Ignore the host free-space percentage while retaining real disk I/O.
for _, pool := range obj.(*erasureServerPools).serverPools {
for _, set := range pool.sets {
original := set.getDisks
disks := append([]StorageAPI(nil), original()...)
for i := range disks {
disks[i] = tagTestCapacityDisk{StorageAPI: disks[i]}
}
set.getDisks = func() []StorageAPI { return disks }
defer func() { set.getDisks = original }()
}
}
oldKMS, oldAuto := GlobalKMS, globalAutoEncryption
GlobalKMS = kms.NewStub("replica-tags-key")
globalAutoEncryption = false
+408 -106
View File
@@ -139,7 +139,13 @@ func (api objectAPIHandlers) SelectObjectContentHandler(w http.ResponseWriter, r
// Check for auth type to return S3 compatible error.
// type to return the correct error (NoSuchKey vs AccessDenied)
if s3Error := checkRequestAuthType(ctx, r, policy.GetObjectAction, bucket, object); s3Error != ErrNone {
logger.GetReqInfo(ctx).BucketName = bucket
logger.GetReqInfo(ctx).ObjectName = object
s3Error := authenticateRequest(ctx, r, policy.GetObjectAction)
if s3Error == ErrNone {
s3Error = authorizeVersionedRequestWithPotentialTags(ctx, r, policy.GetObjectAction, opts.VersionID)
}
if s3Error != ErrNone {
if getRequestAuthType(r) == authTypeAnonymous {
// As per "Permission" section in
// https://docs.aws.amazon.com/AmazonS3/latest/API/RESTObjectGET.html
@@ -199,6 +205,19 @@ func (api objectAPIHandlers) SelectObjectContentHandler(w http.ResponseWriter, r
gopts.NoLock = true // We already have a lock, we can live with it.
objInfo, err := getObjectInfo(ctx, bucket, object, gopts)
if err != nil {
if s3Error := authorizeVersionedRequestWithUnreadTags(ctx, r, policy.GetObjectAction, opts.VersionID, err); s3Error != ErrNone {
if getRequestAuthType(r) == authTypeAnonymous && toAPIError(ctx, err).Code == "NoSuchKey" &&
globalPolicySys.IsAllowed(policy.BucketPolicyArgs{
Action: policy.ListBucketAction,
BucketName: bucket,
ConditionValues: getConditionValues(r, "", auth.AnonymousCredentials),
IsOwner: false,
}) {
s3Error = ErrNoSuchKey
}
writeErrorResponse(ctx, w, errorCodes.ToAPIErr(s3Error), r.URL)
return
}
// Versioning enabled quite possibly object is deleted might be delete-marker
// if present set the headers, no idea why AWS S3 sets these headers.
if objInfo.VersionID != "" && objInfo.DeleteMarker {
@@ -208,10 +227,14 @@ func (api objectAPIHandlers) SelectObjectContentHandler(w http.ResponseWriter, r
writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL)
return
}
if s3Error := authorizeVersionedRequestWithExistingTags(ctx, r, policy.GetObjectAction, opts.VersionID, objInfo.UserTags); s3Error != ErrNone {
writeErrorResponse(ctx, w, errorCodes.ToAPIErr(s3Error), r.URL)
return
}
// filter object lock metadata if permission does not permit
getRetPerms := checkRequestAuthType(ctx, r, policy.GetObjectRetentionAction, bucket, object)
legalHoldPerms := checkRequestAuthType(ctx, r, policy.GetObjectLegalHoldAction, bucket, object)
getRetPerms := authorizeRequest(ctx, r, policy.GetObjectRetentionAction)
legalHoldPerms := authorizeRequest(ctx, r, policy.GetObjectLegalHoldAction)
// filter object lock metadata if permission does not permit
objInfo.UserDefined = objectlock.FilterObjectLockMetadata(objInfo.UserDefined, getRetPerms != ErrNone, legalHoldPerms != ErrNone)
@@ -326,6 +349,8 @@ func (api objectAPIHandlers) getObjectHandler(ctx context.Context, objectAPI Obj
// Check for auth type to return S3 compatible error.
// type to return the correct error (NoSuchKey vs AccessDenied)
logger.GetReqInfo(ctx).BucketName = bucket
logger.GetReqInfo(ctx).ObjectName = object
if s3Error := authenticateRequest(ctx, r, policy.GetObjectAction); s3Error != ErrNone {
if getRequestAuthType(r) == authTypeAnonymous {
// As per "Permission" section in
@@ -395,14 +420,16 @@ 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)
storedTags = &oi.UserTags
if s3Error := authorizeVersionedRequestWithExistingTags(ctx, r, policy.GetObjectAction, opts.VersionID, oi.UserTags); s3Error != ErrNone {
writeErrorResponse(ctx, w, errorCodes.ToAPIErr(s3Error), r.URL)
return true
}
if s3Error := authorizeRequestWithExistingTags(ctx, r, policy.GetObjectAction, oi.UserTags); s3Error != ErrNone {
writeErrorResponse(ctx, w, errorCodes.ToAPIErr(s3Error), r.URL)
if _, err := DecryptObjectInfo(&oi, r); err != nil {
writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL)
return true
}
@@ -421,7 +448,7 @@ func (api objectAPIHandlers) getObjectHandler(ctx context.Context, objectAPI Obj
if (isErrObjectNotFound(err) || isErrVersionNotFound(err) || isErrReadQuorum(err)) && (gr == nil || !gr.ObjInfo.DeleteMarker) {
proxytgts := getProxyTargets(ctx, bucket, object, opts)
if !proxytgts.Empty() {
if !proxytgts.Empty() && authorizeVersionedRequestWithPotentialTags(ctx, r, policy.GetObjectAction, opts.VersionID) == ErrNone {
globalReplicationStats.Load().incProxy(bucket, getObjectAPI, false)
// proxy to replication target if active-active replication is in place.
reader, proxy, perr = proxyGetToReplicationTarget(ctx, bucket, object, rs, r.Header, opts, proxytgts)
@@ -439,14 +466,29 @@ func (api objectAPIHandlers) getObjectHandler(ctx context.Context, objectAPI Obj
}
}
if reader == nil || !proxy.Proxy {
// The conditional callback has already written 304/412. Do not
// The conditional callback has already written 304/412 (or 403). Do not
// authorize again without the stored tags or write a second response.
if isErrPreconditionFailed(err) {
if isErrPreconditionFailed(err) || isErrPreconditionFailed(perr) {
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 = authorizeVersionedRequestWithExistingTags(ctx, r, policy.GetObjectAction, opts.VersionID, *storedTags)
} else {
readErr := err
if proxy.Err != nil {
readErr = proxy.Err
}
if getRequestAuthType(r) == authTypeAnonymous && toAPIError(ctx, readErr).Code == "NoSuchKey" &&
authorizeBucketRequest(ctx, r, policy.ListBucketAction) == ErrNone {
s3Error = ErrNone
} else {
s3Error = authorizeVersionedRequestWithUnreadTags(ctx, r, policy.GetObjectAction, opts.VersionID, readErr)
}
}
if s3Error != ErrNone {
writeErrorResponse(ctx, w, errorCodes.ToAPIErr(s3Error), r.URL)
return
}
@@ -478,6 +520,12 @@ func (api objectAPIHandlers) getObjectHandler(ctx context.Context, objectAPI Obj
defer gr.Close()
objInfo := gr.ObjInfo
if storedTags == nil {
if s3Error := authorizeVersionedRequestWithExistingTags(ctx, r, policy.GetObjectAction, opts.VersionID, objInfo.UserTags); s3Error != ErrNone {
writeErrorResponse(ctx, w, errorCodes.ToAPIErr(s3Error), r.URL)
return
}
}
if !proxy.Proxy { // apply lifecycle rules only for local requests
// Automatically remove the object/version if an expiry lifecycle rule can be applied
@@ -539,6 +587,10 @@ func (api objectAPIHandlers) getObjectHandler(ctx context.Context, objectAPI Obj
hash.AddChecksumHeader(w, cs)
}
if opts.Tagging && !canReturnObjectTagsHeader(ctx, r, opts.VersionID, objInfo.UserTags) {
opts.Tagging = false
}
if err = setObjectHeaders(ctx, w, objInfo, rs, opts); err != nil {
writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL)
return
@@ -597,20 +649,14 @@ func (api objectAPIHandlers) getObjectAttributesHandler(ctx context.Context, obj
return
}
var s3Error APIErrorCode
actions := [2]policy.Action{policy.GetObjectAttributesAction, policy.GetObjectAction}
if opts.VersionID != "" {
s3Error = checkRequestAuthType(ctx, r, policy.GetObjectVersionAttributesAction, bucket, object)
if s3Error == ErrNone {
s3Error = checkRequestAuthType(ctx, r, policy.GetObjectVersionAction, bucket, object)
}
} else {
s3Error = checkRequestAuthType(ctx, r, policy.GetObjectAttributesAction, bucket, object)
if s3Error == ErrNone {
s3Error = checkRequestAuthType(ctx, r, policy.GetObjectAction, bucket, object)
}
actions = [2]policy.Action{policy.GetObjectVersionAttributesAction, policy.GetObjectVersionAction}
}
if s3Error != ErrNone {
logger.GetReqInfo(ctx).BucketName = bucket
logger.GetReqInfo(ctx).ObjectName = object
logger.GetReqInfo(ctx).VersionID = opts.VersionID
if s3Error := authenticateRequest(ctx, r, actions[0]); s3Error != ErrNone {
writeErrorResponse(ctx, w, errorCodes.ToAPIErr(s3Error), r.URL)
return
}
@@ -622,13 +668,30 @@ 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)
// Both actions must be authorized with the stored tags. Authorizing first
// with empty tags would reject conditional Allows and miss conditional Denies.
for _, action := range actions {
var s3Error APIErrorCode
if err != nil {
s3Error = authorizeRequestWithUnreadTags(ctx, r, action, err)
} else {
s3Error = authorizeRequestWithExistingTags(ctx, r, action, objInfo.UserTags)
}
if s3Error != ErrNone {
writeErrorResponse(ctx, w, errorCodes.ToAPIErr(s3Error), r.URL)
return
}
writeErrorResponse(ctx, w, errorCodes.ToAPIErr(ErrAccessDenied), r.URL)
}
if err != nil {
// 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, apiErr, r.URL)
return
}
@@ -833,6 +896,8 @@ func (api objectAPIHandlers) headObjectHandler(ctx context.Context, objectAPI Ob
// Check for auth type to return S3 compatible error.
// type to return the correct error (NoSuchKey vs AccessDenied)
logger.GetReqInfo(ctx).BucketName = bucket
logger.GetReqInfo(ctx).ObjectName = object
if s3Error := authenticateRequest(ctx, r, policy.GetObjectAction); s3Error != ErrNone {
if getRequestAuthType(r) == authTypeAnonymous {
// As per "Permission" section in
@@ -910,25 +975,43 @@ func (api objectAPIHandlers) headObjectHandler(ctx context.Context, objectAPI Ob
if err != nil && !objInfo.DeleteMarker && (isErrObjectNotFound(err) || isErrVersionNotFound(err) || isErrReadQuorum(err)) {
// proxy HEAD to replication target if active-active replication configured on bucket
proxytgts := getProxyTargets(ctx, bucket, object, opts)
if !proxytgts.Empty() {
if !proxytgts.Empty() && authorizeVersionedRequestWithPotentialTags(ctx, r, policy.GetObjectAction, opts.VersionID) == ErrNone {
globalReplicationStats.Load().incProxy(bucket, headObjectAPI, false)
var oi ObjectInfo
oi, proxy = proxyHeadToReplicationTarget(ctx, bucket, object, rs, opts, proxytgts)
if proxy.Proxy {
if proxy.Proxy && proxy.Err == nil {
objInfo = oi
}
if proxy.Err != nil {
globalReplicationStats.Load().incProxy(bucket, headObjectAPI, true)
writeErrorResponseHeadersOnly(w, toAPIError(ctx, proxy.Err))
return
}
}
}
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) || proxy.Err != nil {
readErr := err
if proxy.Err != nil {
readErr = proxy.Err
}
if getRequestAuthType(r) == authTypeAnonymous && toAPIError(ctx, readErr).Code == "NoSuchKey" &&
authorizeBucketRequest(ctx, r, policy.ListBucketAction) == ErrNone {
s3Error = ErrNone
} else {
s3Error = authorizeVersionedRequestWithUnreadTags(ctx, r, policy.GetObjectAction, opts.VersionID, readErr)
}
} else {
s3Error = authorizeVersionedRequestWithExistingTags(ctx, r, policy.GetObjectAction, opts.VersionID, objInfo.UserTags)
}
if s3Error != ErrNone {
writeErrorResponseHeadersOnly(w, errorCodes.ToAPIErr(s3Error))
return
}
if proxy.Err != nil {
writeErrorResponseHeadersOnly(w, toAPIError(ctx, proxy.Err))
return
}
if err != nil && !proxy.Proxy {
switch {
@@ -1033,6 +1116,10 @@ func (api objectAPIHandlers) headObjectHandler(ctx context.Context, objectAPI Ob
hash.AddChecksumHeader(w, cs)
}
if opts.Tagging && !canReturnObjectTagsHeader(ctx, r, opts.VersionID, objInfo.UserTags) {
opts.Tagging = false
}
// Set standard object headers.
if err = setObjectHeaders(ctx, w, objInfo, rs, opts); err != nil {
writeErrorResponseHeadersOnly(w, toAPIError(ctx, err))
@@ -1292,13 +1379,11 @@ func isRemoteCallRequired(ctx context.Context, bucket string, objAPI ObjectLayer
// copyRewritesObjectData reports whether the object layer stores new object data
// for this copy instead of updating metadata in place or adding a
// self-referential version. It mirrors the metadata-only decision taken by
// erasureServerPools.CopyObject and erasureSets.CopyObject. CopyObjectHandler
// has to predict that decision because the compression metadata it records must
// describe whichever bytes are finally stored. metadataOnly already excludes
// legacy sources, which the object layer always rewrites.
// self-referential version. The selected pool must contain the source data to
// add a reference. metadataOnly already excludes legacy sources, which the
// object layer always rewrites.
func copyRewritesObjectData(metadataOnly bool, srcOpts, dstOpts ObjectOptions) bool {
if !metadataOnly {
if !metadataOnly || dstOpts.copyPlan != nil && dstOpts.copyPlan.rewrite {
return true
}
switch {
@@ -1343,9 +1428,23 @@ func (api objectAPIHandlers) CopyObjectHandler(w http.ResponseWriter, r *http.Re
return
}
if s3Error := checkRequestAuthType(ctx, r, policy.PutObjectAction, dstBucket, dstObject); s3Error != ErrNone {
writeErrorResponse(ctx, w, errorCodes.ToAPIErr(s3Error), r.URL)
return
if isDirectiveReplace(r.Header.Get(xhttp.AmzTagDirective)) {
if s3Error := checkRequestAuthType(ctx, r, policy.PutObjectAction, dstBucket, dstObject); s3Error != ErrNone {
writeErrorResponse(ctx, w, errorCodes.ToAPIErr(s3Error), r.URL)
return
}
} else {
logger.GetReqInfo(ctx).BucketName = dstBucket
logger.GetReqInfo(ctx).ObjectName = dstObject
logger.GetReqInfo(ctx).VersionID = ""
if s3Error := authenticateRequest(ctx, r, policy.PutObjectAction); s3Error != ErrNone {
writeErrorResponse(ctx, w, errorCodes.ToAPIErr(s3Error), r.URL)
return
}
if !canPotentiallyAllowAction(ctx, r, policy.PutObjectAction, dstBucket, dstObject) {
writeErrorResponse(ctx, w, errorCodes.ToAPIErr(ErrAccessDenied), r.URL)
return
}
}
// Read escaped copy source path to check for parameters.
@@ -1379,7 +1478,9 @@ func (api objectAPIHandlers) CopyObjectHandler(w http.ResponseWriter, r *http.Re
}
}
if s3Error := checkRequestAuthType(ctx, r, policy.GetObjectAction, srcBucket, srcObject); s3Error != ErrNone {
if s3Error := withScopedReqObject(ctx, srcBucket, srcObject, vid, func() APIErrorCode {
return authorizeVersionedRequestWithPotentialTags(ctx, r, policy.GetObjectAction, vid)
}); s3Error != ErrNone {
writeErrorResponse(ctx, w, errorCodes.ToAPIErr(s3Error), r.URL)
return
}
@@ -1463,7 +1564,15 @@ func (api objectAPIHandlers) CopyObjectHandler(w http.ResponseWriter, r *http.Re
getObjectNInfo := objectAPI.GetObjectNInfo
var storedSrcTags *string
checkCopyPrecondFn := func(o ObjectInfo) bool {
storedSrcTags = &o.UserTags
if s3Error := withScopedReqObject(ctx, srcBucket, srcObject, vid, func() APIErrorCode {
return authorizeVersionedRequestWithExistingTags(ctx, r, policy.GetObjectAction, vid, o.UserTags)
}); s3Error != ErrNone {
writeErrorResponse(ctx, w, errorCodes.ToAPIErr(s3Error), r.URL)
return true
}
if _, err := DecryptObjectInfo(&o, r); err != nil {
writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL)
return true
@@ -1515,6 +1624,20 @@ func (api objectAPIHandlers) CopyObjectHandler(w http.ResponseWriter, r *http.Re
if isErrPreconditionFailed(err) {
return
}
var s3Error APIErrorCode
if storedSrcTags != nil {
s3Error = withScopedReqObject(ctx, srcBucket, srcObject, vid, func() APIErrorCode {
return authorizeVersionedRequestWithExistingTags(ctx, r, policy.GetObjectAction, vid, *storedSrcTags)
})
} else {
s3Error = withScopedReqObject(ctx, srcBucket, srcObject, vid, func() APIErrorCode {
return authorizeVersionedRequestWithUnreadTags(ctx, r, policy.GetObjectAction, vid, err)
})
}
if s3Error != ErrNone {
writeErrorResponse(ctx, w, errorCodes.ToAPIErr(s3Error), r.URL)
return
}
// Versioning enabled quite possibly object is deleted might be delete-marker
// if present set the headers, no idea why AWS S3 sets these headers.
@@ -1532,6 +1655,23 @@ func (api objectAPIHandlers) CopyObjectHandler(w http.ResponseWriter, r *http.Re
}
defer gr.Close()
srcInfo := gr.ObjInfo
if storedSrcTags == nil {
if s3Error := withScopedReqObject(ctx, srcBucket, srcObject, vid, func() APIErrorCode {
return authorizeVersionedRequestWithExistingTags(ctx, r, policy.GetObjectAction, vid, srcInfo.UserTags)
}); s3Error != ErrNone {
writeErrorResponse(ctx, w, errorCodes.ToAPIErr(s3Error), r.URL)
return
}
}
if !isDirectiveReplace(r.Header.Get(xhttp.AmzTagDirective)) {
copiedTags := srcInfo.UserTags
if s3Error := withScopedReqObject(ctx, dstBucket, dstObject, "", func() APIErrorCode {
return authorizeRequestWithTags(ctx, r, policy.PutObjectAction, "", &copiedTags)
}); s3Error != ErrNone {
writeErrorResponse(ctx, w, errorCodes.ToAPIErr(s3Error), r.URL)
return
}
}
// A trusted SSE-C replica reads raw ciphertext. Federation's ordinary PUT
// expects plaintext and does not forward the replica's sealed-key metadata.
if remoteCallRequired && replicaTrusted && crypto.SSEC.IsEncrypted(srcInfo.UserDefined) {
@@ -1673,16 +1813,23 @@ func (api objectAPIHandlers) CopyObjectHandler(w http.ResponseWriter, r *http.Re
// Without a requested destination checksum algorithm, an in-place SSE-C
// rotation preserves the stored checksum state, including absence; it does
// not add the default CRC64NVME.
canRotateKeyInPlace := !srcInfo.Legacy &&
canRotateKeyInPlace := cpSrcDstSame && sseCopyC && sseC && !chStorageClass && !srcInfo.Legacy &&
!copyRewritesObjectData(srcInfo.metadataOnly, copySrcOpts, dstOpts) &&
(replicaTrusted || !hash.NewChecksumHeader(r.Header).IsSet())
// If src == dst and either
// - the object is encrypted using SSE-C and two different SSE-C keys are present
// - the object is encrypted using SSE-S3 and the SSE-S3 header is present
// - the object storage class is not changing
// then execute a key rotation.
if cpSrcDstSame && (sseCopyC && sseC) && !chStorageClass && canRotateKeyInPlace {
if canRotateKeyInPlace {
// A new version may need another pool. Resolve that before rewrapping
// the key, since a cross-pool copy must re-encrypt the source stream.
plan, err := objectAPI.prepareCopyObject(lockCtx, srcBucket, srcObject, dstBucket, dstObject, srcInfo, copySrcOpts, dstOpts)
if err != nil {
writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL)
return
}
dstOpts.copyPlan = &plan
canRotateKeyInPlace = !plan.rewrite
}
if canRotateKeyInPlace {
oldKey, err = ParseSSECopyCustomerRequest(r.Header, srcInfo.UserDefined)
if err != nil {
writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL)
@@ -1773,6 +1920,20 @@ func (api objectAPIHandlers) CopyObjectHandler(w http.ResponseWriter, r *http.Re
}
}
if !remoteCallRequired && dstOpts.copyPlan == nil {
// Encryption and checksum recomputation require new data. Plan
// with that final decision before starting either transform, but
// retain metadataOnly for the invalid self-copy check below.
planInfo := srcInfo
planInfo.metadataOnly = srcInfo.metadataOnly && !dstOpts.WantServerSideChecksumType.IsSet()
plan, err := objectAPI.prepareCopyObject(lockCtx, srcBucket, srcObject, dstBucket, dstObject, planInfo, copySrcOpts, dstOpts)
if err != nil {
writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL)
return
}
dstOpts.copyPlan = &plan
}
if isDstCompressed {
checksumReader := srcInfo.Reader
wantEncryption := crypto.Requested(r.Header)
@@ -1829,12 +1990,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 +2035,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 +2046,18 @@ 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)
}
if !isDirectiveReplace(r.Header.Get(xhttp.AmzTagDirective)) {
finalDstTags := srcInfo.UserDefined[xhttp.AmzObjectTagging]
if finalDstTags != srcInfo.UserTags {
if s3Error := withScopedReqObject(ctx, dstBucket, dstObject, "", func() APIErrorCode {
return authorizeRequestWithTags(ctx, r, policy.PutObjectAction, "", &finalDstTags)
}); s3Error != ErrNone {
writeErrorResponse(ctx, w, errorCodes.ToAPIErr(s3Error), r.URL)
return
}
}
}
// 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 +2065,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)
@@ -2236,9 +2421,13 @@ func (api objectAPIHandlers) PutObjectHandler(w http.ResponseWriter, r *http.Req
writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL)
return
}
if size < 0 {
writeErrorResponse(ctx, w, errorCodes.ToAPIErr(ErrInvalidArgument), r.URL)
return
}
}
}
if size == -1 {
if size < 0 {
writeErrorResponse(ctx, w, errorCodes.ToAPIErr(ErrMissingContentLength), r.URL)
return
}
@@ -2701,10 +2890,14 @@ func (api objectAPIHandlers) PutObjectExtractHandler(w http.ResponseWriter, r *h
writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL)
return
}
if size < 0 {
writeErrorResponse(ctx, w, errorCodes.ToAPIErr(ErrInvalidArgument), r.URL)
return
}
}
}
if size == -1 {
if size < 0 {
writeErrorResponse(ctx, w, errorCodes.ToAPIErr(ErrMissingContentLength), r.URL)
return
}
@@ -3140,6 +3333,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 +3369,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) {
@@ -3190,28 +3391,44 @@ func (api objectAPIHandlers) DeleteObjectHandler(w http.ResponseWriter, r *http.
return dsc, nil
})
_, rcfg, err := checkBucketDeleteMetadata(bucket)
if err != nil {
writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL)
return
}
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
}
}
checkUnaddressedLock := vID == "" && !opts.Versioned && !opts.DeleteMarker && rcfg.LockEnabled
if checkUnaddressedLock {
opts.RefuseLockedOverwrite = true
}
opts.SetEvalRetentionBypassFn(func(goi ObjectInfo, gerr error) (err error) {
err = nil
if vID != "" {
if vID != "" || checkUnaddressedLock || (vID == "" && !opts.Versioned && !opts.DeleteMarker && gerr == nil) {
checkVID := vID
if checkVID == "" {
checkVID = nullVersionID
}
err := enforceRetentionBypassForDelete(ctx, r, bucket, ObjectToDelete{
ObjectV: ObjectV{
ObjectName: object,
VersionID: vID,
VersionID: checkVID,
},
}, goi, gerr)
if err != nil && !isErrObjectNotFound(err) {
return err
}
}
return err
return nil
})
deleteObject := objectAPI.DeleteObject
@@ -3363,7 +3580,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 +3757,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)
@@ -3665,6 +3882,8 @@ func (api objectAPIHandlers) GetObjectTaggingHandler(w http.ResponseWriter, r *h
return
}
logger.GetReqInfo(ctx).BucketName = bucket
logger.GetReqInfo(ctx).ObjectName = object
if s3Error := authenticateRequest(ctx, r, policy.GetObjectTaggingAction); s3Error != ErrNone {
writeErrorResponse(ctx, w, errorCodes.ToAPIErr(s3Error), r.URL)
return
@@ -3684,9 +3903,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 := authorizeVersionedRequestWithPotentialTags(ctx, r, policy.GetObjectTaggingAction, opts.VersionID); 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 authorizeVersionedRequestWithUnreadTags(ctx, r, policy.GetObjectTaggingAction, opts.VersionID, readErr)
}
return authorizeVersionedRequestWithExistingTags(ctx, r, policy.GetObjectTaggingAction, opts.VersionID, 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,16 +3927,24 @@ 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 := authorizeVersionedRequestWithExistingTags(ctx, r, policy.GetObjectTaggingAction, opts.VersionID, ""); s3Error != ErrNone {
writeErrorResponse(ctx, w, errorCodes.ToAPIErr(s3Error), r.URL)
return
}
writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL)
return
}
} else {
if s3Error := authorizeVersionedRequestWithUnreadTags(ctx, r, policy.GetObjectTaggingAction, opts.VersionID, err); 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.GetObjectTaggingAction, ot.String()); s3Error != ErrNone {
if s3Error := authorizeVersionedRequestWithExistingTags(ctx, r, policy.GetObjectTaggingAction, opts.VersionID, ot.String()); s3Error != ErrNone {
writeErrorResponse(ctx, w, errorCodes.ToAPIErr(s3Error), r.URL)
return
}
@@ -3733,6 +3973,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 +4004,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 +4019,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 +4032,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 +4039,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 := authorizeVersionedRequestWithPotentialTags(ctx, r, policy.PutObjectTaggingAction, opts.VersionID); 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 authorizeVersionedRequestWithUnreadTags(ctx, r, policy.PutObjectTaggingAction, opts.VersionID, readErr)
}
return authorizeVersionedRequestWithExistingTags(ctx, r, policy.PutObjectTaggingAction, opts.VersionID, 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 +4074,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 = authorizeVersionedRequestWithExistingTags(ctx, r, policy.PutObjectTaggingAction, opts.VersionID, "")
} else {
s3Error = authorizeVersionedRequestWithUnreadTags(ctx, r, policy.PutObjectTaggingAction, opts.VersionID, err)
}
if s3Error != ErrNone {
writeErrorResponse(ctx, w, errorCodes.ToAPIErr(s3Error), r.URL)
return
}
writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL)
return
}
if s3Error := authorizeVersionedRequestWithExistingTags(ctx, r, policy.PutObjectTaggingAction, opts.VersionID, 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 +4150,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 +4171,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 := authorizeVersionedRequestWithPotentialTags(ctx, r, policy.DeleteObjectTaggingAction, opts.VersionID); 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 authorizeVersionedRequestWithUnreadTags(ctx, r, policy.DeleteObjectTaggingAction, opts.VersionID, readErr)
}
return authorizeVersionedRequestWithExistingTags(ctx, r, policy.DeleteObjectTaggingAction, opts.VersionID, 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 +4207,22 @@ func (api objectAPIHandlers) DeleteObjectTaggingHandler(w http.ResponseWriter, r
return
}
}
var s3Error APIErrorCode
if isErrObjectNotFound(err) || isErrVersionNotFound(err) {
s3Error = authorizeVersionedRequestWithExistingTags(ctx, r, policy.DeleteObjectTaggingAction, opts.VersionID, "")
} else {
s3Error = authorizeVersionedRequestWithUnreadTags(ctx, r, policy.DeleteObjectTaggingAction, opts.VersionID, 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 := authorizeVersionedRequestWithExistingTags(ctx, r, policy.DeleteObjectTaggingAction, opts.VersionID, oi.UserTags); s3Error != ErrNone {
writeErrorResponse(ctx, w, errorCodes.ToAPIErr(s3Error), r.URL)
return
}
+421
View File
@@ -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))
}})
}
+41 -2
View File
@@ -427,7 +427,9 @@ func (api objectAPIHandlers) CopyObjectPartHandler(w http.ResponseWriter, r *htt
}
}
if s3Error := checkRequestAuthType(ctx, r, policy.GetObjectAction, srcBucket, srcObject); s3Error != ErrNone {
if s3Error := withScopedReqObject(ctx, srcBucket, srcObject, vid, func() APIErrorCode {
return authorizeVersionedRequestWithPotentialTags(ctx, r, policy.GetObjectAction, vid)
}); s3Error != ErrNone {
writeErrorResponse(ctx, w, errorCodes.ToAPIErr(s3Error), r.URL)
return
}
@@ -491,7 +493,15 @@ func (api objectAPIHandlers) CopyObjectPartHandler(w http.ResponseWriter, r *htt
}
}
var storedSrcTags *string
checkCopyPartPrecondFn := func(o ObjectInfo) bool {
storedSrcTags = &o.UserTags
if s3Error := withScopedReqObject(ctx, srcBucket, srcObject, vid, func() APIErrorCode {
return authorizeVersionedRequestWithExistingTags(ctx, r, policy.GetObjectAction, vid, o.UserTags)
}); s3Error != ErrNone {
writeErrorResponse(ctx, w, errorCodes.ToAPIErr(s3Error), r.URL)
return true
}
if _, err := DecryptObjectInfo(&o, r); err != nil {
writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL)
return true
@@ -513,6 +523,20 @@ func (api objectAPIHandlers) CopyObjectPartHandler(w http.ResponseWriter, r *htt
if isErrPreconditionFailed(err) {
return
}
var s3Error APIErrorCode
if storedSrcTags != nil {
s3Error = withScopedReqObject(ctx, srcBucket, srcObject, vid, func() APIErrorCode {
return authorizeVersionedRequestWithExistingTags(ctx, r, policy.GetObjectAction, vid, *storedSrcTags)
})
} else {
s3Error = withScopedReqObject(ctx, srcBucket, srcObject, vid, func() APIErrorCode {
return authorizeVersionedRequestWithUnreadTags(ctx, r, policy.GetObjectAction, vid, err)
})
}
if s3Error != ErrNone {
writeErrorResponse(ctx, w, errorCodes.ToAPIErr(s3Error), r.URL)
return
}
if globalBucketVersioningSys.PrefixEnabled(srcBucket, srcObject) && gr != nil {
// Versioning enabled quite possibly object is deleted might be delete-marker
// if present set the headers, no idea why AWS S3 sets these headers.
@@ -521,11 +545,22 @@ func (api objectAPIHandlers) CopyObjectPartHandler(w http.ResponseWriter, r *htt
w.Header()[xhttp.AmzDeleteMarker] = []string{strconv.FormatBool(gr.ObjInfo.DeleteMarker)}
}
}
reqInfo := logger.GetReqInfo(ctx)
reqInfo.BucketName = srcBucket
reqInfo.ObjectName = srcObject
writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL)
return
}
defer gr.Close()
srcInfo := gr.ObjInfo
if storedSrcTags == nil {
if s3Error := withScopedReqObject(ctx, srcBucket, srcObject, vid, func() APIErrorCode {
return authorizeVersionedRequestWithExistingTags(ctx, r, policy.GetObjectAction, vid, srcInfo.UserTags)
}); s3Error != ErrNone {
writeErrorResponse(ctx, w, errorCodes.ToAPIErr(s3Error), r.URL)
return
}
}
actualPartSize, err := srcInfo.GetActualSize()
if err != nil {
@@ -788,10 +823,14 @@ func (api objectAPIHandlers) PutObjectPartHandler(w http.ResponseWriter, r *http
writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL)
return
}
if size < 0 {
writeErrorResponse(ctx, w, errorCodes.ToAPIErr(ErrInvalidArgument), r.URL)
return
}
}
}
if size == -1 {
if size < 0 {
writeErrorResponse(ctx, w, errorCodes.ToAPIErr(ErrMissingContentLength), r.URL)
return
}
+1 -1
View File
@@ -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 {
+3 -3
View File
@@ -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 {
+47
View File
@@ -0,0 +1,47 @@
// Copyright (c) 2026 PGSTY
// SPDX-License-Identifier: AGPL-3.0-or-later
package cmd
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestIAMImportRejectsUnknownPolicyFields(t *testing.T) {
_, _ = iamAdminFixture(t)
for _, raw := range []string{
`{"Version":"2012-10-17","Statements":[]}`,
`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:GetObject"],"Resource":["arn:aws:s3:::b/*"],"Conditions":{"StringEquals":{"s3:ExistingObjectTag/classification":"public"}}}]}`,
} {
data := iamImportArchive(t, map[string]any{allPoliciesFile: map[string]json.RawMessage{"import-policy": json.RawMessage(raw)}})
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.StatusBadRequest {
t.Fatalf("import malformed policy: got %d %s, want 400", rec.Code, rec.Body.String())
}
}
}
func TestSessionPolicyWriteKeepsStrictSemanticValidation(t *testing.T) {
raw := []byte(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["admin:GetBucketQuota"],"Resource":["arn:aws:s3:::mybucket"],"NotResource":["arn:aws:s3:::otherbucket"]}]}`)
if _, err := parseSessionPolicyStrictBytes(raw); err == nil {
t.Fatal("new session policy accepted conflicting Resource and NotResource")
}
if _, err := parsePolicySnapshotBytes(raw); err != nil {
t.Fatalf("historical snapshot compatibility lost: %v", err)
}
}
func TestBucketPolicyRejectsUnsupportedNotPrincipal(t *testing.T) {
raw := []byte(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","NotPrincipal":{"AWS":"excluded-user"},"Action":"s3:GetObject","Resource":"arn:aws:s3:::b/*"}]}`)
if _, err := parseBucketPolicyStrictBytes(raw, "b"); err == nil {
t.Fatal("unsupported NotPrincipal was silently discarded")
}
}
+159
View File
@@ -0,0 +1,159 @@
// Copyright (c) 2026 PGSTY
//
// 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.
package cmd
import (
"bytes"
"encoding/json"
"io"
"github.com/pgsty/silo-pkg/v3/policy"
)
type strictIAMStatementWire struct {
SID policy.ID `json:"Sid,omitempty"`
Effect policy.Effect `json:"Effect"`
Actions json.RawMessage `json:"Action,omitempty"`
NotActions json.RawMessage `json:"NotAction,omitempty"`
Resources json.RawMessage `json:"Resource,omitempty"`
NotResources json.RawMessage `json:"NotResource,omitempty"`
Conditions json.RawMessage `json:"Condition,omitempty"`
}
type strictIAMPolicyWire struct {
ID policy.ID `json:"ID,omitempty"`
Version string `json:"Version"`
Statements []strictIAMStatementWire `json:"Statement"`
}
type strictPrincipalWire struct {
AWS json.RawMessage `json:"AWS"`
}
type strictBucketStatementWire struct {
SID policy.ID `json:"Sid,omitempty"`
Effect policy.Effect `json:"Effect"`
Principal json.RawMessage `json:"Principal,omitempty"`
Actions json.RawMessage `json:"Action,omitempty"`
NotActions json.RawMessage `json:"NotAction,omitempty"`
Resources json.RawMessage `json:"Resource,omitempty"`
NotResources json.RawMessage `json:"NotResource,omitempty"`
Conditions json.RawMessage `json:"Condition,omitempty"`
}
type strictBucketPolicyWire struct {
ID policy.ID `json:"ID,omitempty"`
Version string `json:"Version"`
Statements []strictBucketStatementWire `json:"Statement"`
}
func validateStrictIAMPolicyWire(data []byte) ([]byte, error) {
trimmed := bytes.TrimSpace(data)
if len(trimmed) == 0 || trimmed[0] != '{' {
return nil, policy.Errorf("policy must be a JSON object")
}
var wire strictIAMPolicyWire
dec := json.NewDecoder(bytes.NewReader(trimmed))
dec.DisallowUnknownFields()
if err := dec.Decode(&wire); err != nil {
return nil, policy.Errorf("%w", err)
}
var extra any
if err := dec.Decode(&extra); err != io.EOF {
if err == nil {
return nil, policy.Errorf("unexpected trailing JSON data in policy")
}
return nil, policy.Errorf("%w", err)
}
return trimmed, nil
}
func parsePolicyStrictBytes(data []byte) (*policy.Policy, error) {
trimmed, err := validateStrictIAMPolicyWire(data)
if err != nil {
return nil, err
}
p, err := policy.ParseConfigStrict(bytes.NewReader(trimmed))
if err != nil {
return nil, err
}
if p.IsEmpty() {
return nil, policy.Errorf("empty policies are not allowed")
}
return p, nil
}
func parseSessionPolicyStrictBytes(data []byte) (*policy.Policy, error) {
trimmed, err := validateStrictIAMPolicyWire(data)
if err != nil {
return nil, err
}
return policy.ParseConfigStrict(bytes.NewReader(trimmed))
}
// Snapshot imports retain supported historical policy semantics while rejecting
// unknown JSON fields that could silently discard restrictions.
func parsePolicySnapshotBytes(data []byte) (*policy.Policy, error) {
trimmed, err := validateStrictIAMPolicyWire(data)
if err != nil {
return nil, err
}
return policy.ParseConfig(bytes.NewReader(trimmed))
}
func validateStrictPrincipalWire(raw json.RawMessage) error {
trimmed := bytes.TrimSpace(raw)
if len(trimmed) == 0 || trimmed[0] != '{' {
return nil
}
var pw strictPrincipalWire
dec := json.NewDecoder(bytes.NewReader(trimmed))
dec.DisallowUnknownFields()
if err := dec.Decode(&pw); err != nil {
return policy.Errorf("%w", err)
}
var extra any
if err := dec.Decode(&extra); err != io.EOF {
if err == nil {
return policy.Errorf("unexpected trailing JSON data in principal")
}
return policy.Errorf("%w", err)
}
if len(bytes.TrimSpace(pw.AWS)) == 0 {
return policy.Errorf("invalid principal '%s'", string(raw))
}
return nil
}
func parseBucketPolicyStrictBytes(data []byte, bucket string) (*policy.BucketPolicy, error) {
trimmed := bytes.TrimSpace(data)
if len(trimmed) == 0 || trimmed[0] != '{' {
return nil, policy.Errorf("bucket policy must be a JSON object")
}
var wire strictBucketPolicyWire
dec := json.NewDecoder(bytes.NewReader(trimmed))
dec.DisallowUnknownFields()
if err := dec.Decode(&wire); err != nil {
return nil, policy.Errorf("%w", err)
}
var extra any
if err := dec.Decode(&extra); err != io.EOF {
if err == nil {
return nil, policy.Errorf("unexpected trailing JSON data in bucket policy")
}
return nil, policy.Errorf("%w", err)
}
for _, st := range wire.Statements {
if err := validateStrictPrincipalWire(st.Principal); err != nil {
return nil, err
}
}
return policy.ParseBucketPolicyConfig(bytes.NewReader(trimmed), bucket)
}
+162
View File
@@ -0,0 +1,162 @@
// Copyright (c) 2026 PGSTY
// SPDX-License-Identifier: AGPL-3.0-or-later
package cmd
import (
"bufio"
"encoding/base64"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/minio/minio/internal/auth"
xhttp "github.com/minio/minio/internal/http"
"github.com/pgsty/silo-pkg/v3/policy"
)
type trailerFaultReader struct {
err error
reads int
}
func (r *trailerFaultReader) Read([]byte) (int, error) {
r.reads++
if r.reads > 2 {
return 0, io.EOF // Bound a broken parser rather than exhausting test memory.
}
return 0, r.err
}
func TestTrailerReadPreservesConnectionErrors(t *testing.T) {
for _, signed := range []bool{false, true} {
for _, prefix := range []string{"", "checksum:value\r", "checksum:value\r\n", "checksum:value\r\nx-amz-trailer-signature:a\r"} {
if !signed && strings.Contains(prefix, "\r\n") {
continue
}
t.Run(fmt.Sprintf("signed=%t/prefix=%q", signed, prefix), func(t *testing.T) {
failure := errors.New("injected connection reset")
fault := &trailerFaultReader{err: failure}
reader := bufio.NewReader(io.MultiReader(strings.NewReader(prefix), fault))
var err error
if signed {
err = (&s3ChunkedReader{reader: reader}).readTrailers()
} else {
err = (&s3UnsignedChunkedReader{reader: reader}).readTrailers()
}
if !errors.Is(err, failure) || fault.reads != 1 {
t.Fatalf("got err=%v reads=%d, want original connection error after one read", err, fault.reads)
}
})
}
}
}
func TestLatestMetadataListingDoesNotRequireVersionGrants(t *testing.T) {
defer DetectTestLeak(t)()
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
t: t,
objAPITest: func(obj ObjectLayer, backend, bucket string, router http.Handler, _ auth.Credentials, t *testing.T) {
_, err := globalBucketMetadataSys.Update(t.Context(), bucket, bucketVersioningConfig, enabledBucketVersioningConfig)
mustIAM(t, err)
_, err = obj.PutObject(t.Context(), bucket, "latest", mustGetPutObjReader(t, strings.NewReader("data"), 4, "", ""), ObjectOptions{
Versioned: true, UserDefined: map[string]string{xhttp.AmzObjectTagging: "classification=public"},
})
mustIAM(t, err)
cred := createTestPolicyUser(t, fmt.Sprintf(`{"Version":"2012-10-17","Statement":[
{"Effect":"Allow","Action":["s3:ListBucket","s3:ListBucketVersions"],"Resource":["arn:aws:s3:::%s"]},
{"Effect":"Allow","Action":["s3:GetObject","s3:GetObjectTagging"],"Resource":["arn:aws:s3:::%s/*"]},
{"Effect":"Deny","Action":["s3:GetObjectVersion","s3:GetObjectVersionTagging"],"Resource":["arn:aws:s3:::%s/*"]}]}`, bucket, bucket, bucket))
for _, query := range []string{"list-type=2", "versions="} {
req, err := newTestSignedRequestV4(http.MethodGet, "/"+bucket+"?"+query+"&metadata=true&prefix=latest", 0, nil, cred.AccessKey, cred.SecretKey, nil)
mustIAM(t, err)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
wantTags := query == "list-type=2"
if rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), "classification=public") != wantTags {
t.Fatalf("%s %s: status=%d body=%s, want tags=%t", backend, query, rec.Code, rec.Body.String(), wantTags)
}
}
},
})
}
func TestVersionDeniesSurviveInheritedAndSessionPolicies(t *testing.T) {
defer DetectTestLeak(t)()
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{t: t, objAPITest: func(obj ObjectLayer, backend, bucket string, router http.Handler, _ auth.Credentials, t *testing.T) {
ctx := t.Context()
_, err := globalBucketMetadataSys.Update(ctx, bucket, bucketVersioningConfig, enabledBucketVersioningConfig)
mustIAM(t, err)
oi, err := obj.PutObject(ctx, bucket, "item", mustGetPutObjReader(t, strings.NewReader("data"), 4, "", ""), ObjectOptions{
Versioned: true, UserDefined: map[string]string{xhttp.AmzObjectTagging: "classification=public"},
})
mustIAM(t, err)
allow := fmt.Sprintf(`{"Effect":"Allow","Action":["s3:GetObject","s3:GetObjectTagging"],"Resource":["arn:aws:s3:::%s/*"]}`, bucket)
deny := fmt.Sprintf(`{"Effect":"Deny","Action":["s3:GetObjectVersion","s3:GetObjectVersionTagging"],"Resource":["arn:aws:s3:::%s/*"],"Condition":{"StringEquals":{"s3:versionid":%q}}}`, bucket, oi.VersionID)
document := func(statements string) string {
return `{"Version":"2012-10-17","Statement":[` + statements + `]}`
}
baseOnly := createTestPolicyUser(t, document(allow))
denied := createTestPolicyUser(t, document(allow+","+deny))
inherited, _, err := globalIAMSys.NewServiceAccount(ctx, denied.AccessKey, nil, newServiceAccountOpts{})
mustIAM(t, err)
session, err := policy.ParseConfig(strings.NewReader(document(allow + "," + deny)))
mustIAM(t, err)
inline, _, err := globalIAMSys.NewServiceAccount(ctx, globalActiveCred.AccessKey, nil, newServiceAccountOpts{sessionPolicy: session})
mustIAM(t, err)
secret, err := getTokenSigningKey()
mustIAM(t, err)
temporary, err := auth.GetNewCredentialsWithMetadata(map[string]any{
parentClaim: globalActiveCred.AccessKey, "exp": UTCNow().Add(time.Hour).Unix(),
policy.SessionPolicyName: base64.StdEncoding.EncodeToString([]byte(document(allow + "," + deny))),
}, secret)
mustIAM(t, err)
temporary.ParentUser = globalActiveCred.AccessKey
_, err = globalIAMSys.SetTempUser(ctx, temporary.AccessKey, temporary, "")
mustIAM(t, err)
groupUser := createTestPolicyUser(t, document(allow))
groupPolicy, err := policy.ParseConfig(strings.NewReader(document(deny)))
mustIAM(t, err)
_, err = globalIAMSys.SetPolicy(ctx, "version-deny", *groupPolicy)
mustIAM(t, err)
_, err = globalIAMSys.AddUsersToGroup(ctx, "version-deny-group", []string{groupUser.AccessKey})
mustIAM(t, err)
_, err = globalIAMSys.PolicyDBSet(ctx, "version-deny-group", "version-deny", regUser, true)
mustIAM(t, err)
for _, tc := range []struct {
name string
cred auth.Credentials
deny bool
}{
{"legacy-base-grant", baseOnly, false},
{"regular", denied, true},
{"inherited-service-account", inherited, true},
{"root-service-account-session", inline, true},
{"root-sts-session", temporary, true},
{"group", groupUser, true},
} {
for _, query := range []string{"", "?versionId=" + oi.VersionID, "?tagging=&versionId=" + oi.VersionID} {
headers := map[string]string{xhttp.AmzTagDirective: "ACCESS"}
if tc.cred.IsTemp() {
headers[xhttp.AmzSecurityToken] = tc.cred.SessionToken
}
req, err := newTestSignedRequestV4(http.MethodGet, "/"+bucket+"/item"+query, 0, nil, tc.cred.AccessKey, tc.cred.SecretKey, headers)
mustIAM(t, err)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
want := http.StatusOK
if tc.deny && query != "" {
want = http.StatusForbidden
}
if rec.Code != want {
t.Errorf("%s %s %s: got %d %s, want %d", backend, tc.name, query, rec.Code, rec.Body.String(), want)
}
}
}
}})
}

Some files were not shown because too many files have changed in this diff Show More