mirror of
https://github.com/pgsty/minio.git
synced 2026-11-11 10:05:34 +03:00
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)
This commit is contained in:
@@ -169,8 +169,9 @@ func TestIAMAdminEmptyPolicyDelegation(t *testing.T) {
|
|||||||
{"{}", http.StatusOK, true},
|
{"{}", http.StatusOK, true},
|
||||||
{`{"Statement":null}`, http.StatusOK, true},
|
{`{"Statement":null}`, http.StatusOK, true},
|
||||||
{limitedPolicy, http.StatusOK, false},
|
{limitedPolicy, http.StatusOK, false},
|
||||||
{`{"Version":"2012-10-17"}`, http.StatusForbidden, false},
|
{`{"Version":"2012-10-17"}`, http.StatusOK, true},
|
||||||
{`{"Statement":[]}`, http.StatusForbidden, false},
|
{`{"Statement":[]}`, http.StatusOK, true},
|
||||||
|
{`{"Version":"2012-10-17","Statement":[]}`, http.StatusOK, true},
|
||||||
} {
|
} {
|
||||||
expires := UTCNow().Add(time.Hour)
|
expires := UTCNow().Add(time.Hour)
|
||||||
req := madmin.AddServiceAccountReq{Expiration: &expires}
|
req := madmin.AddServiceAccountReq{Expiration: &expires}
|
||||||
|
|||||||
@@ -2618,8 +2618,17 @@ func (a adminAPIHandlers) importIAM(w http.ResponseWriter, r *http.Request, apiV
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if _, gerr := globalIAMSys.AddUsersToGroup(ctx, group, grpInfo.Members); gerr != nil {
|
_, gerr := globalIAMSys.AddUsersToGroup(ctx, group, grpInfo.Members)
|
||||||
failed.Groups = append(failed.Groups, madmin.IAMErrEntity{Name: group, Error: err})
|
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 {
|
} else {
|
||||||
added.Groups = append(added.Groups, group)
|
added.Groups = append(added.Groups, group)
|
||||||
}
|
}
|
||||||
@@ -2699,6 +2708,7 @@ func (a adminAPIHandlers) importIAM(w http.ResponseWriter, r *http.Request, apiV
|
|||||||
opts := newServiceAccountOpts{
|
opts := newServiceAccountOpts{
|
||||||
accessKey: user,
|
accessKey: user,
|
||||||
secretKey: svcAcctReq.SecretKey,
|
secretKey: svcAcctReq.SecretKey,
|
||||||
|
status: svcAcctReq.Status,
|
||||||
sessionPolicy: sp,
|
sessionPolicy: sp,
|
||||||
claims: svcAcctReq.Claims,
|
claims: svcAcctReq.Claims,
|
||||||
name: svcAcctReq.Name,
|
name: svcAcctReq.Name,
|
||||||
@@ -3055,7 +3065,7 @@ func commonAddServiceAccount(r *http.Request, ldap bool) (context.Context, auth.
|
|||||||
}
|
}
|
||||||
// Empty service policies mean inheritance, including for expiring keys.
|
// Empty service policies mean inheritance, including for expiring keys.
|
||||||
// Normalize the replication request as well as the local credential.
|
// Normalize the replication request as well as the local credential.
|
||||||
if sp.Version == "" && sp.Statements == nil && sp.ID == "" {
|
if sp.IsEmpty() {
|
||||||
sp = nil
|
sp = nil
|
||||||
createReq.Policy = nil
|
createReq.Policy = nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
-20
@@ -20,7 +20,6 @@ package cmd
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"maps"
|
"maps"
|
||||||
@@ -2934,27 +2933,15 @@ func updatedServiceAccountCredentials(ctx context.Context, accessKey string, cr
|
|||||||
// the embedded one stays. Only a policy that was given, and is empty,
|
// 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
|
// removes it: the account then has all its parent is allowed, which is
|
||||||
// usually more.
|
// usually more.
|
||||||
reset := opts.sessionPolicy != nil && opts.sessionPolicy.Version == "" && len(opts.sessionPolicy.Statements) == 0
|
if opts.sessionPolicy != nil {
|
||||||
if _, ok := m.Lookup(policy.SessionPolicyName); ok && reset {
|
policyBuf, err := marshalServiceAccountPolicy(opts.sessionPolicy)
|
||||||
m.Delete(policy.SessionPolicyName)
|
if err != nil {
|
||||||
m.Set(iamPolicyClaimNameSA(), inheritedPolicyType)
|
|
||||||
}
|
|
||||||
|
|
||||||
if opts.sessionPolicy != nil { // session policies is being updated
|
|
||||||
if err := opts.sessionPolicy.Validate(); err != nil {
|
|
||||||
return cr, err
|
return cr, err
|
||||||
}
|
}
|
||||||
|
if len(policyBuf) == 0 {
|
||||||
if opts.sessionPolicy.Version != "" && len(opts.sessionPolicy.Statements) > 0 {
|
m.Delete(policy.SessionPolicyName)
|
||||||
policyBuf, err := json.Marshal(opts.sessionPolicy)
|
m.Set(iamPolicyClaimNameSA(), inheritedPolicyType)
|
||||||
if err != nil {
|
} else {
|
||||||
return cr, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(policyBuf) > maxSVCSessionPolicySize {
|
|
||||||
return cr, errSessionPolicyTooLarge
|
|
||||||
}
|
|
||||||
|
|
||||||
// Overwrite session policy claims.
|
// Overwrite session policy claims.
|
||||||
m.Set(policy.SessionPolicyName, base64.StdEncoding.EncodeToString(policyBuf))
|
m.Set(policy.SessionPolicyName, base64.StdEncoding.EncodeToString(policyBuf))
|
||||||
m.Set(iamPolicyClaimNameSA(), embeddedPolicyType)
|
m.Set(iamPolicyClaimNameSA(), embeddedPolicyType)
|
||||||
|
|||||||
@@ -4,9 +4,12 @@
|
|||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"reflect"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -25,6 +28,133 @@ import (
|
|||||||
|
|
||||||
const svcAccEditPolicy = `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:GetObject"],"Resource":["arn:aws:s3:::edit/*"]}]}`
|
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) {
|
func TestIAMServiceAccountEditKeepsPolicy(t *testing.T) {
|
||||||
f := newGroupRefreshFixture(t)
|
f := newGroupRefreshFixture(t)
|
||||||
want, err := policy.ParseConfig(strings.NewReader(svcAccEditPolicy))
|
want, err := policy.ParseConfig(strings.NewReader(svcAccEditPolicy))
|
||||||
@@ -110,6 +240,7 @@ func TestIAMServiceAccountEditKeepsPolicy(t *testing.T) {
|
|||||||
{"peer-status", madmin.SRSvcAccUpdate{Status: auth.AccountOff}, true, func(c auth.Credentials) bool { return c.Status == auth.AccountOff }},
|
{"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-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", 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) {
|
t.Run(edit.name, func(t *testing.T) {
|
||||||
key := "edit-" + edit.name
|
key := "edit-" + edit.name
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"path"
|
"path"
|
||||||
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -59,6 +60,11 @@ func svcAccStored(t *testing.T, f *groupRefreshFixture, key string) svcAccRecord
|
|||||||
t.Fatalf("service account %s is not stored", key)
|
t.Fatalf("service account %s is not stored", key)
|
||||||
}
|
}
|
||||||
cr := u.Credentials
|
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)
|
claims, err := getClaimsFromTokenWithSecret(cr.SessionToken, cr.SecretKey)
|
||||||
mustIAM(t, err)
|
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}
|
rec := svcAccRecord{Parent: cr.ParentUser, Secret: cr.SecretKey, Status: cr.Status, Name: cr.Name, Description: cr.Description, Expiration: cr.Expiration, UpdatedAt: u.UpdatedAt}
|
||||||
@@ -172,6 +178,11 @@ func svcAccPeer(t *testing.T, from *svcAccSite, key string, items ...iamReplicat
|
|||||||
mustIAM(t, err)
|
mustIAM(t, err)
|
||||||
var received iamReplicationItem
|
var received iamReplicationItem
|
||||||
mustIAM(t, json.Unmarshal(raw, &received))
|
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 {
|
if err := applyIAMReplicationItem(peer.ctx, received); err != nil {
|
||||||
t.Fatalf("the peer refused item %d: %v", i, err)
|
t.Fatalf("the peer refused item %d: %v", i, err)
|
||||||
}
|
}
|
||||||
@@ -215,15 +226,19 @@ func TestSiteReplicationServiceAccountEdits(t *testing.T) {
|
|||||||
{NewDescription: "described"},
|
{NewDescription: "described"},
|
||||||
{NewPolicy: json.RawMessage(`{"Statement":[]}`)},
|
{NewPolicy: json.RawMessage(`{"Statement":[]}`)},
|
||||||
}, func(r svcAccRecord) bool { return r.Policy == "" && r.Description == "described" }},
|
}, func(r svcAccRecord) bool { return r.Policy == "" && r.Description == "described" }},
|
||||||
// Policy documents an edit ignores: one with a version and no
|
{"policy-removed-with-version", []madmin.UpdateServiceAccountReq{
|
||||||
// statement, one with statements and no version. The site keeps the
|
|
||||||
// policy it has, and so does the peer.
|
|
||||||
{"policy-ignored", []madmin.UpdateServiceAccountReq{
|
|
||||||
{NewPolicy: json.RawMessage(svcAccSnapshotPolicy)},
|
{NewPolicy: json.RawMessage(svcAccSnapshotPolicy)},
|
||||||
{NewPolicy: json.RawMessage(`{"Version":"2012-10-17","Statement":[]}`), NewName: "named"},
|
{NewPolicy: json.RawMessage(`{"Version":"2012-10-17","Statement":[]}`), NewName: "named"},
|
||||||
{NewPolicy: json.RawMessage(`{"Statement":[{"Effect":"Allow","Action":["s3:PutObject"],"Resource":["arn:aws:s3:::ignored/*"]}]}`), NewDescription: "described"},
|
{NewDescription: "described"},
|
||||||
}, func(r svcAccRecord) bool {
|
}, func(r svcAccRecord) bool {
|
||||||
return r.Policy == svcAccPolicyText(t, []byte(svcAccSnapshotPolicy)) && r.Name == "named" && r.Description == "described"
|
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"} {
|
for _, delivery := range []string{"late", "lost"} {
|
||||||
@@ -360,9 +375,42 @@ func TestSiteReplicationServiceAccountStaleCache(t *testing.T) {
|
|||||||
if a.Claims[iamPolicyClaimNameSA()] != embeddedPolicyType {
|
if a.Claims[iamPolicyClaimNameSA()] != embeddedPolicyType {
|
||||||
t.Errorf("exported claims say %v, want %s", 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
|
// The parent was deleted and created again on both sites. The token of a
|
||||||
// service account created afterwards names that revocation, and a peer
|
// service account created afterwards names that revocation, and a peer
|
||||||
// refuses a credential of the parent that does not: the create must carry
|
// refuses a credential of the parent that does not: the create must carry
|
||||||
|
|||||||
+40
-14
@@ -1098,6 +1098,32 @@ type newServiceAccountOpts struct {
|
|||||||
claims map[string]any
|
claims map[string]any
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// marshalServiceAccountPolicy validates a supplied policy and normalizes its
|
||||||
|
// version without changing the caller's policy. An empty policy means inheritance.
|
||||||
|
func marshalServiceAccountPolicy(sp *policy.Policy) ([]byte, error) {
|
||||||
|
if sp == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
p := *sp
|
||||||
|
if p.Version == "" {
|
||||||
|
p.Version = policy.DefaultVersion
|
||||||
|
}
|
||||||
|
if err := p.Validate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if p.IsEmpty() {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
buf, err := json.Marshal(p)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(buf) > maxSVCSessionPolicySize {
|
||||||
|
return nil, errSessionPolicyTooLarge
|
||||||
|
}
|
||||||
|
return buf, nil
|
||||||
|
}
|
||||||
|
|
||||||
// NewServiceAccount - create a new service account
|
// NewServiceAccount - create a new service account
|
||||||
func (sys *IAMSys) NewServiceAccount(ctx context.Context, parentUser string, groups []string, opts newServiceAccountOpts) (auth.Credentials, time.Time, error) {
|
func (sys *IAMSys) NewServiceAccount(ctx context.Context, parentUser string, groups []string, opts newServiceAccountOpts) (auth.Credentials, time.Time, error) {
|
||||||
if !sys.Initialized() {
|
if !sys.Initialized() {
|
||||||
@@ -1115,19 +1141,9 @@ func (sys *IAMSys) NewServiceAccount(ctx context.Context, parentUser string, gro
|
|||||||
return auth.Credentials{}, time.Time{}, auth.ErrNoAccessKeyWithSecretKey
|
return auth.Credentials{}, time.Time{}, auth.ErrNoAccessKeyWithSecretKey
|
||||||
}
|
}
|
||||||
|
|
||||||
var policyBuf []byte
|
policyBuf, err := marshalServiceAccountPolicy(opts.sessionPolicy)
|
||||||
if opts.sessionPolicy != nil {
|
if err != nil {
|
||||||
err := opts.sessionPolicy.Validate()
|
return auth.Credentials{}, time.Time{}, err
|
||||||
if err != nil {
|
|
||||||
return auth.Credentials{}, time.Time{}, err
|
|
||||||
}
|
|
||||||
policyBuf, err = json.Marshal(opts.sessionPolicy)
|
|
||||||
if err != nil {
|
|
||||||
return auth.Credentials{}, time.Time{}, err
|
|
||||||
}
|
|
||||||
if len(policyBuf) > maxSVCSessionPolicySize {
|
|
||||||
return auth.Credentials{}, time.Time{}, errSessionPolicyTooLarge
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// found newly requested service account, to be same as
|
// found newly requested service account, to be same as
|
||||||
@@ -1150,6 +1166,14 @@ func (sys *IAMSys) NewServiceAccount(ctx context.Context, parentUser string, gro
|
|||||||
|
|
||||||
// Add all the necessary claims for the service account.
|
// Add all the necessary claims for the service account.
|
||||||
for k, v := range opts.claims {
|
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]
|
_, ok := m[k]
|
||||||
if !ok {
|
if !ok {
|
||||||
m[k] = v
|
m[k] = v
|
||||||
@@ -1162,7 +1186,6 @@ func (sys *IAMSys) NewServiceAccount(ctx context.Context, parentUser string, gro
|
|||||||
}
|
}
|
||||||
|
|
||||||
var accessKey, secretKey string
|
var accessKey, secretKey string
|
||||||
var err error
|
|
||||||
if len(opts.accessKey) > 0 || len(opts.secretKey) > 0 {
|
if len(opts.accessKey) > 0 || len(opts.secretKey) > 0 {
|
||||||
accessKey, secretKey = opts.accessKey, opts.secretKey
|
accessKey, secretKey = opts.accessKey, opts.secretKey
|
||||||
} else {
|
} else {
|
||||||
@@ -1344,6 +1367,9 @@ func serviceAccountSnapshot(u UserIdentity) (madmin.SRSvcAccCreate, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return madmin.SRSvcAccCreate{}, err
|
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)
|
embeddedPolicy, err := embeddedServiceAccountPolicy(jwtClaims)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return madmin.SRSvcAccCreate{}, err
|
return madmin.SRSvcAccCreate{}, err
|
||||||
|
|||||||
Reference in New Issue
Block a user