iam: manage OIDC providers and roles over the filer IAM gRPC service (#11523)

* s3/iam: manage roles through the IAM API, with an opt-in persistent role store

Roles could only come from the IAM config file: the S3 server pinned the
role store to memory and the embedded IAM API had no role actions, so a
role could not be created, retrusted or revoked without editing the file
and restarting every gateway.

Role store
- Read the `roleStore` key (the IAMConfig field already existed). With an
  IAM config file the default stays memory; with none it is the filer, as
  for OIDC providers, so zero-config clusters keep runtime-created roles.
- Roles from the IAM config file never go into a persistent role store,
  which outlives the file and may be shared by S3 servers with different
  files. They are served from memory beneath the store, as OIDC providers
  are: a stored role of the same name takes precedence, and deleting it
  restores the file's. A config-file role cannot be changed or deleted
  through the API (UnmodifiableEntity), and removing one from the file
  removes it at the next start. An in-memory store holds them as records,
  as before. They have no creation time, so CreateDate is omitted rather
  than reporting when this server started. SetRoleStore installs a store
  the same way, so a store set after startup keeps the config-file roles,
  as SetOIDCProviderStore does for providers.
- Watch /etc/iam/roles and drop the cached role definitions on change. The
  cached filer store otherwise serves a peer's stale role for up to its 5m
  TTL, which keeps a revoked trust policy in force on the other gateways.
- Role stores wrap ErrRoleNotFound for a missing role; the filer store
  used to report any failed lookup as "role not found". CreateRole proceeds
  only on a confirmed absence, so an unreadable store cannot let it write
  over an existing role.

IAM actions
- CreateRole, GetRole, ListRoles, DeleteRole, UpdateAssumeRolePolicy,
  AttachRolePolicy, DetachRolePolicy, ListAttachedRolePolicies. The reads
  are allowed in read-only mode.
- A role defined in the config file is reloaded from it at every start, so
  changing or deleting it through the API is refused (UnmodifiableEntity)
  rather than silently reverted.
- DeleteRole with policies attached is refused (DeleteConflict), as on AWS.
- Role names follow AWS's rules ([\w+=,.@-]{1,64}); a role is stored as
  <name>.json in the filer, so this also keeps a name from leaving the role
  store's directory. At most 10 managed policies per role (AWS's default
  quota; MaxManagedPoliciesPerUser is 10 too), LimitExceeded beyond.
- DeletePolicy is refused (DeleteConflict) while a role attaches the
  policy, as it already is for users and groups: roles attach policies by
  name, so a policy created later under the deleted one's name would
  otherwise take effect on the role.
- Role paths other than "/" and role tags are not stored, so they are
  refused rather than dropped.

Role IDs and sessions
- Roles get a unique RoleId when first stored (random, AWS AROA form),
  kept across updates; a config-file role gets a stable ID derived from its
  name, since it is created again at every start.
- Sessions issued through AssumeRoleWithWebIdentity, AssumeRoleWithCredentials
  and AssumeRole carry the role's ID (claim "rid"), and a request under a role
  whose current ID differs is denied. Resolving a session's policies by role
  name let a session outlive its role: once a role was deleted, a role later
  created under the same name — with a different trust policy and different
  policies — revived every unexpired session of the old one with the new
  role's permissions. Sessions issued before this change carry no ID and are
  unaffected until they expire.

Integration test (test/s3/iam, run with `make start-services`):
TestWebIdentityWithProviderAndRoleManagedThroughIAMAPI configures an OIDC
provider, a managed policy and a role entirely through the IAM API against a
JWKS served by the test, then checks the trusted subject gets credentials
scoped to the attached policy; another subject, a token signed by another
key, an unsigned token and a token for another audience are refused; and UpdateAssumeRolePolicy moves the
trust at once.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* iam: manage OIDC providers and roles over the filer IAM gRPC service

The filer's SeaweedIdentityAccessManagement service covers users, access
keys, policies and service accounts, but not the OIDC providers and roles
that STS web-identity federation needs. A controller that already manages
IAM over this service (seaweedfs-operator's S3OIDCProvider) has no
transport for them; its swadmin client returns ErrOIDCNotWired and names
this as the recommended fix.

- PutOIDCProvider / GetOIDCProvider / DeleteOIDCProvider / ListOIDCProviders
  and PutRole / GetRole / DeleteRole / ListRoles.
- They write the filer-backed stores at their default paths, which S3
  servers read when configured with a filer-typed "oidcProviderStore" and
  "roleStore"; the S3 servers' /etc/iam subscription applies changes
  without a restart.
- Put is an upsert, so a controller can reconcile to it. Deleting a
  provider or role that does not exist returns NotFound, as DeleteUser does
  for a user; clients treat that as already deleted. The provider's account
  ID travels in the request, since the filer does not know the STS
  accountId.
- PutRole applies the IAM API's rules: AWS role names, at most 10 managed
  policies.
- An S3 server serves the roles and providers of its own IAM config file
  ahead of the store, so a stored entry with the same name has no effect
  on that server.
- PutRole keeps a replaced role's RoleId and gives a role created anew a
  fresh one, so sessions of a deleted role do not carry over to a later role
  of the same name.
- DeletePolicy returns FailedPrecondition while a role attaches the policy
  (see the IAM API's DeleteConflict in the previous change). DeletePolicy on
  this service still does not check user attachments, which predates this.
- PutOIDCProvider requires an https issuer (http only for a loopback host):
  STS fetches the issuer's signing keys from it, so over plain HTTP anyone
  on the network path could substitute their own.
- The OIDC provider and role RPCs refuse to run on an unauthenticated
  service (FailedPrecondition until jwt.filer_signing.key is set). Users and
  policies keep the service's opt-in auth, but these grant STS access
  outright: otherwise anyone who can reach the port could register an issuer
  they control, create a role trusting it, and exchange a token for S3
  credentials. The filer's unauthenticated notice becomes a warning that says
  so.
- A store that cannot be read is Unavailable, never "not found", so a Put
  never writes over an entry it could not see.
- Validation is shared with the IAM API through PrepareRoleDefinition and
  PrepareOIDCProviderRecord.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* s3/iam: bind every role session to its role, and change roles atomically

Review follow-ups.

Session binding
- The role-ID check ran only when a session carried no policy names, and
  AssumeRole embeds the role's attached policies, so those sessions kept
  their permissions after the role was deleted or recreated. The check
  now runs for every session carrying a role ID, before policy selection.
- A named role that cannot be resolved at issuance gets no session,
  instead of one with no role ID (which nothing binds).
- A config-file role's ID is derived from its name and trust policy, not
  the name alone: a different role put in the file under the same name
  gets a new ID, while an unchanged role keeps its sessions across restarts.

Role writes
- RoleStore gains UpdateRole, a read-modify-write that lands only if the
  role is unchanged since the read, and otherwise re-reads and retries. The
  filer store uses the filer's write conditions (IF_NOT_EXISTS for a new
  role, IF_ENTRY_EQUAL otherwise). CreateRole, UpdateAssumeRolePolicy and
  Attach/DetachRolePolicy all go through it, so two gateways no longer
  overwrite each other's changes, a change racing a delete no longer
  writes the role back, and of two concurrent creates one gets
  EntityAlreadyExists.
- The filer store's ListRoles pages past 1,000 entries and fails on a
  broken stream instead of returning what arrived, so DeletePolicy's
  attachment check sees every role. ListRoles skips a role deleted between
  listing and reading it.
- CreateRole validates first; a failed write is ServiceFailure, not
  InvalidInput. Any Tags.* parameter is refused, not only the first key.
- ExecuteAction's skipPersist covers the S3ApiConfiguration only; the
  comment now says so. Role and OIDC provider actions write their own stores.

Each fix has a test that fails without it. Against a real filer with two
gateways, concurrent AttachRolePolicy calls lost 1-4 of 8 attachments per
run before this change and none after.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* iam: PutRole changes roles atomically and checks its ARN; https issuers' keys stay on https

Review follow-ups on top of the role-store changes.

- PutRole goes through RoleStore.UpdateRole, so the decision to keep an
  existing role's ID or mint a new one is made against the role as it is
  when written. A PutRole racing a DeleteRole can no longer write the
  deleted role back with its old ID, which would revive its sessions. A
  failed store read or write is Unavailable.
- PutRole refuses a role_arn that does not name the role: STS resolves a
  role by the name in the ARN it is given.
- PutOIDCProvider requires an https issuer, but discovery could still name
  a plain-http jwks_uri, and a key fetch could be redirected to http. For
  an https issuer, a non-https jwks_uri from discovery is refused (the
  issuer's own /.well-known/jwks.json is used instead), and the client
  that fetches discovery and keys refuses any https-to-http redirect. An
  operator-set jwksUri is left as configured.

Each has a test that fails without its guard.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* s3/iam: one role snapshot per decision; DeleteRole is atomic; watch a custom role store path

Review follow-ups.

- Authorization evaluates the policies of the role definition the session's
  binding was checked against, instead of reading the role again: a role
  replaced in between cannot lend a session its policies.
- AssumeRole and AssumeRoleWithLDAPIdentity issue the session from the
  definition whose trust admits the caller (IAMManager.ResolveRoleForPrincipal),
  and take its ID, duration cap and embedded policies from that same
  definition. A role replaced after the caller's trust check by one that does
  not trust the caller now yields AccessDenied, not a session bound to the
  replacement.
- A RoleUpdate that returns nil deletes the role, on the same condition as a
  write: the filer store deletes with ObjectTransaction on IF_ENTRY_EQUAL,
  routed and locked like the conditional CreateEntry. DeleteRole decides
  against the role it deletes, so a policy attached meanwhile on another
  server is a DeleteConflict, and a delete never removes a role written
  after its check.
- S3 servers watch the role store's configured basePath, not only
  /etc/iam/roles, so a custom path also drops peers' cached roles on change.

Each has a test that fails without it. Live against a real filer: DeleteRole
refuses while a policy is attached and removes the entry once detached; all
test/s3/iam CI stages pass.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* s3/iam: state which roles DeletePolicy's attachment check can see

RolesAttachingPolicy sees the stored roles and this server's config-file
roles. A role defined only in another server's IAM config file is invisible
to it, so a config-file role that attaches a managed policy is protected
only on the servers whose file defines it. The doc comment now says so and
how to avoid it: keep such roles in every server's file, or attach only
config-file policies to config-file roles.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* iam: note that a role store set after startup is not watched for peer changes

S3 servers build their metadata watch list once, at startup, from the role
store installed then. SetRoleStore's doc now says that a filer-backed store
installed later with a different basePath is not watched, so peers' changes
to it reach this server's cached roles only when the cache expires.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* iam: DeleteRole deletes only the role it saw; issuer URLs are bare

Review follow-ups.

- The filer IAM service's DeleteRole looked the role up, then deleted by
  name, so a PutRole landing in between had its new definition deleted. It
  now deletes through RoleStore.UpdateRole, conditional on the entry it
  read. If the role was replaced meanwhile, it returns Aborted rather than
  deleting the replacement, and the caller decides again.
- PutOIDCProvider refuses an issuer URL with userinfo, a query or a
  fragment. The provider's ARN comes from host and path alone, while STS
  matches a token's iss claim against the stored URL exactly, so such a
  provider shared the bare issuer's ARN and matched no token. A loopback
  "localhost" is now matched without regard to case.

Both have tests that fail without them.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* iam: write OIDC providers atomically over the filer IAM gRPC service

PutOIDCProvider read the record, then stored unconditionally; a racing
DeleteOIDCProvider left the put's stale read merged into the rewritten
record. DeleteOIDCProvider read, then deleted unconditionally; a racing
PutOIDCProvider's newer record could be removed instead. These are the
races the role RPCs closed with UpdateRole.

OIDCProviderStore gains UpdateProvider with the same contract: memory
under its lock, filer as a conditional write (IF_ENTRY_EQUAL /
IF_NOT_EXISTS) or conditional delete retrying a changed entry.
PutOIDCProvider merges the fields the request cannot carry against the
record as it is written; DeleteOIDCProvider aborts rather than delete a
record replaced meanwhile.

isRoleWriteConflict is renamed isEntryWriteConflict — the conditional-
write check is shared by both stores now.

* iam: guard PutRole against a nil credential manager, fix its doc comment

PutRole read attached policies through s.credentialManager without the
nil check its sibling handlers make, so a server built without one
panicked on a PutRole naming a policy. It now fails the call as
FailedPrecondition like the others.

The doc comment also had the store/static precedence backwards: a stored
role shadows a same-named config-file role (as the overlay serves it),
not the other way around.

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Chris Lu <chrislusf@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-10-03 08:36:19 +08:00
1 parent fa77cde7da
commit 30069f3e45
12 files changed
+2763 -91

No files matched your search

+11 -1
View File
@@ -23,6 +23,7 @@ import (
_ "github.com/seaweedfs/seaweedfs/weed/credential/postgres"
"github.com/seaweedfs/seaweedfs/weed/filer"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/iam/integration"
"github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
@@ -475,9 +476,18 @@ func (fo *FilerOptions) startFiler() {
if credentialManager != nil {
adminSigningKey := security.SigningKey(util.GetViper().GetString("jwt.filer_signing.key"))
iamGrpcServer := weed_server.NewIamGrpcServer(credentialManager, adminSigningKey)
// The OIDC provider and role RPCs write where S3 servers configured with
// filer-typed "oidcProviderStore" and "roleStore" read: this filer, at
// the stores' default base paths.
selfAddress := func() string { return string(filerAddress) }
if roleStore, err := integration.NewFilerRoleStore(nil, selfAddress); err != nil {
glog.Warningf("IAM gRPC: role RPCs disabled: %v", err)
} else {
iamGrpcServer.SetSTSStores(integration.NewFilerOIDCProviderStore(nil, selfAddress), roleStore)
}
iam_pb.RegisterSeaweedIdentityAccessManagementServer(grpcS, iamGrpcServer)
if len(adminSigningKey) == 0 {
glog.V(0).Info("Registered IAM gRPC service on filer (unauthenticated; set jwt.filer_signing.key in security.toml to require admin Bearer token)")
glog.Warning("IAM gRPC service on filer is UNAUTHENTICATED: anyone who can reach this port can create users and policies, and its OIDC provider and role RPCs are refused; set jwt.filer_signing.key in security.toml to require an admin Bearer token")
} else {
glog.V(0).Info("Registered IAM gRPC service on filer (admin Bearer token required)")
}
+21
View File
@@ -2155,3 +2155,24 @@ func (m *IAMManager) ValidateTrustPolicyForCredentials(ctx context.Context, role
// Use existing trust policy validation logic
return m.validateTrustPolicyForCredentials(ctx, roleDef, mockRequest)
}
// PrepareOIDCProviderRecord builds and validates the record that
// CreateOpenIDConnectProvider stores for an issuer, deriving its ARN from the
// account ID and issuer URL.
func PrepareOIDCProviderRecord(accountID, issuerURL string, clientIDs, thumbprints []string) (*OIDCProviderRecord, error) {
arn, err := DeriveOIDCProviderARN(accountID, issuerURL)
if err != nil {
return nil, err
}
rec := &OIDCProviderRecord{
AccountID: accountID,
ARN: arn,
URL: issuerURL,
ClientIDs: append([]string(nil), clientIDs...),
Thumbprints: append([]string(nil), thumbprints...),
}
if err := validateOIDCProviderRecord(rec); err != nil {
return nil, err
}
return rec, nil
}
+166
View File
@@ -17,7 +17,10 @@ import (
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// Sentinel errors returned by the IAM manager and OIDCProviderStore. Callers
@@ -87,8 +90,27 @@ type OIDCProviderStore interface {
GetProviderByIssuerAndAccount(ctx context.Context, filerAddress string, issuer, accountID string) (*OIDCProviderRecord, error)
ListProviders(ctx context.Context, filerAddress string) ([]*OIDCProviderRecord, error)
DeleteProvider(ctx context.Context, filerAddress string, arn string) error
// UpdateProvider replaces a provider's record with update's result,
// atomically: the write or delete happens only against the record update
// saw, so a change in between applies update again to what the other
// writer left.
UpdateProvider(ctx context.Context, filerAddress string, arn string, update OIDCProviderUpdate) error
}
// OIDCProviderUpdate computes a provider's new record from its current one,
// nil when the provider does not exist. It returns nil to delete the
// provider, and an error to leave it unchanged. It may run more than once: it
// is called again with the fresh record when another writer changed the
// provider in between.
type OIDCProviderUpdate func(current *OIDCProviderRecord) (*OIDCProviderRecord, error)
// maxProviderUpdateAttempts bounds UpdateProvider's retries under contention.
const maxProviderUpdateAttempts = 10
// errProviderUpdateContended is returned when the provider kept changing
// under UpdateProvider for maxProviderUpdateAttempts reads.
var errProviderUpdateContended = errors.New("OIDC provider changed concurrently; retry")
// MemoryOIDCProviderStore is a process-local store, suitable for tests and
// single-node deployments. It also acts as the in-memory cache hydrated from
// static config at boot.
@@ -184,6 +206,27 @@ func (m *MemoryOIDCProviderStore) DeleteProvider(ctx context.Context, _ string,
return nil
}
// UpdateProvider applies update under the store's lock (filerAddress ignored
// for the memory store).
func (m *MemoryOIDCProviderStore) UpdateProvider(ctx context.Context, _ string, arn string, update OIDCProviderUpdate) error {
if arn == "" {
return fmt.Errorf("ARN is required")
}
m.mu.Lock()
defer m.mu.Unlock()
next, err := update(copyOIDCProviderRecord(m.providers[arn]))
if err != nil {
return err
}
if next == nil {
delete(m.providers, arn)
return nil
}
next.ARN = arn
m.providers[arn] = copyOIDCProviderRecord(next)
return nil
}
// FilerOIDCProviderStore persists records as JSON files in a filer directory,
// mirroring FilerRoleStore.
type FilerOIDCProviderStore struct {
@@ -421,6 +464,129 @@ func (f *FilerOIDCProviderStore) DeleteProvider(ctx context.Context, filerAddres
})
}
// UpdateProvider reads the provider's entry, applies update, and writes the
// result on the condition that the entry is unchanged since the read —
// absent, when the provider did not exist — so the filer refuses a write
// racing another writer's change or delete, and update is applied again to
// what that writer left. A delete is made on the same condition, so it
// removes the record update saw and not one written after it.
func (f *FilerOIDCProviderStore) UpdateProvider(ctx context.Context, filerAddress string, arn string, update OIDCProviderUpdate) error {
filerAddress = f.resolveFilerAddress(filerAddress)
if filerAddress == "" {
return fmt.Errorf("filer address is required")
}
if arn == "" {
return fmt.Errorf("ARN is required")
}
return f.withFilerClient(filerAddress, func(client filer_pb.SeaweedFilerClient) error {
for attempt := 0; attempt < maxProviderUpdateAttempts; attempt++ {
var entry *filer_pb.Entry
var current *OIDCProviderRecord
resp, err := filer_pb.LookupEntry(ctx, client, &filer_pb.LookupDirectoryEntryRequest{
Directory: f.basePath,
Name: f.fileName(arn),
})
switch {
case errors.Is(err, filer_pb.ErrNotFound):
case err != nil:
return fmt.Errorf("lookup OIDC provider %s: %w", arn, err)
case resp.Entry != nil:
entry = resp.Entry
current = &OIDCProviderRecord{}
if err := json.Unmarshal(entry.Content, current); err != nil {
return fmt.Errorf("failed to deserialize OIDC provider %s: %v", arn, err)
}
}
next, err := update(current)
if err != nil {
return err
}
if next == nil {
if entry == nil {
return nil
}
deleted, err := f.deleteProviderEntryIfUnchanged(ctx, client, entry)
if err != nil {
return fmt.Errorf("failed to delete OIDC provider %s: %w", arn, err)
}
if !deleted {
glog.V(3).Infof("OIDC provider %s changed before its delete; retrying", arn)
continue
}
return nil
}
next.ARN = arn
data, err := json.MarshalIndent(next, "", " ")
if err != nil {
return fmt.Errorf("failed to serialize OIDC provider %s: %v", arn, err)
}
clause := &filer_pb.WriteCondition_Clause{Kind: filer_pb.WriteCondition_IF_NOT_EXISTS}
if entry != nil {
clause = &filer_pb.WriteCondition_Clause{Kind: filer_pb.WriteCondition_IF_ENTRY_EQUAL, ExpectedEntry: entry}
}
now := time.Now().Unix()
created, err := client.CreateEntry(ctx, &filer_pb.CreateEntryRequest{
Directory: f.basePath,
Entry: &filer_pb.Entry{
Name: f.fileName(arn),
Attributes: &filer_pb.FuseAttributes{
Mtime: now,
Crtime: now,
FileMode: uint32(0600),
},
Content: data,
},
Condition: &filer_pb.WriteCondition{Clauses: []*filer_pb.WriteCondition_Clause{clause}},
})
if isEntryWriteConflict(created, err) {
glog.V(3).Infof("OIDC provider %s changed during update; retrying", arn)
continue
}
if err != nil {
return fmt.Errorf("failed to store OIDC provider %s: %v", arn, err)
}
if created.Error != "" {
return fmt.Errorf("failed to store OIDC provider %s: %s", arn, created.Error)
}
return nil
}
return fmt.Errorf("update OIDC provider %s: %w", arn, errProviderUpdateContended)
})
}
// deleteProviderEntryIfUnchanged deletes the provider's entry if it still
// equals entry, reporting false when it changed. The delete is routed and
// locked as the conditional CreateEntry of the same path is, so the two
// serialize.
func (f *FilerOIDCProviderStore) deleteProviderEntryIfUnchanged(ctx context.Context, client filer_pb.SeaweedFilerClient, entry *filer_pb.Entry) (bool, error) {
fullPath := f.basePath + "/" + entry.Name
resp, err := client.ObjectTransaction(ctx, &filer_pb.ObjectTransactionRequest{
LockKey: fullPath,
RouteKey: s3_constants.ObjectWriteRouteKeyPrefix + fullPath,
Condition: &filer_pb.WriteCondition{Clauses: []*filer_pb.WriteCondition_Clause{{
Kind: filer_pb.WriteCondition_IF_ENTRY_EQUAL, ExpectedEntry: entry,
}}},
Mutations: []*filer_pb.ObjectMutation{{
Type: filer_pb.ObjectMutation_DELETE, Directory: f.basePath, Name: entry.Name, IsDeleteData: true,
}},
})
if err != nil {
if status.Code(err) == codes.FailedPrecondition {
return false, nil
}
return false, err
}
if resp.ErrorCode == filer_pb.FilerError_PRECONDITION_FAILED {
return false, nil
}
if resp.Error != "" {
return false, errors.New(resp.Error)
}
return true, nil
}
func (f *FilerOIDCProviderStore) withFilerClient(filerAddress string, fn func(filer_pb.SeaweedFilerClient) error) error {
return pb.WithGrpcFilerClient(false, 0, pb.ServerAddress(filerAddress), f.grpcDialOption, fn)
}
+3 -3
View File
@@ -358,7 +358,7 @@ func (f *FilerRoleStore) UpdateRole(ctx context.Context, filerAddress string, ro
},
Condition: &filer_pb.WriteCondition{Clauses: []*filer_pb.WriteCondition_Clause{clause}},
})
if isRoleWriteConflict(created, err) {
if isEntryWriteConflict(created, err) {
glog.V(3).Infof("Role %s changed during update; retrying", roleName)
continue
}
@@ -404,10 +404,10 @@ func (f *FilerRoleStore) deleteRoleEntryIfUnchanged(ctx context.Context, client
return true, nil
}
// isRoleWriteConflict reports a write the filer refused because its condition
// isEntryWriteConflict reports a write the filer refused because its condition
// no longer held: in the response, or as FailedPrecondition when the write
// was forwarded to the entry's owner filer.
func isRoleWriteConflict(resp *filer_pb.CreateEntryResponse, err error) bool {
func isEntryWriteConflict(resp *filer_pb.CreateEntryResponse, err error) bool {
if err != nil {
return status.Code(err) == codes.FailedPrecondition
}
+81
View File
@@ -0,0 +1,81 @@
package oidc
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
)
// plainKeyServer serves a JWKS over plain http and counts the fetches: an
// https issuer's keys must never be read from it.
func plainKeyServer(t *testing.T) (*httptest.Server, *atomic.Int32) {
t.Helper()
var hits atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
_ = json.NewEncoder(w).Encode(JWKS{Keys: []JWK{{Kty: "RSA", Kid: "attacker", Use: "sig", Alg: "RS256", N: "AQAB", E: "AQAB"}}})
}))
t.Cleanup(server.Close)
return server, &hits
}
// httpsIssuer starts a TLS issuer whose handlers the test supplies, and a
// provider for it.
func httpsIssuer(t *testing.T, mux *http.ServeMux) (*httptest.Server, *OIDCProvider) {
t.Helper()
server := httptest.NewTLSServer(mux)
t.Cleanup(server.Close)
p := NewOIDCProvider("https-keys")
if err := p.Initialize(&OIDCConfig{Issuer: server.URL, ClientID: "c", TLSInsecureSkipVerify: true}); err != nil {
t.Fatalf("Initialize: %v", err)
}
return server, p
}
// Discovery naming a plain-http jwks_uri for an https issuer is refused; the
// keys come from the issuer's own https host instead.
func TestAnHTTPSIssuersDiscoveredKeysMustBeHTTPS(t *testing.T) {
plain, plainHits := plainKeyServer(t)
var server *httptest.Server
var ownKeyHits atomic.Int32
mux := http.NewServeMux()
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]string{"issuer": server.URL, "jwks_uri": plain.URL + "/jwks"})
})
mux.HandleFunc("/.well-known/jwks.json", func(w http.ResponseWriter, r *http.Request) {
ownKeyHits.Add(1)
_ = json.NewEncoder(w).Encode(JWKS{Keys: []JWK{{Kty: "RSA", Kid: "k1", Use: "sig", Alg: "RS256", N: "AQAB", E: "AQAB"}}})
})
server, p := httpsIssuer(t, mux)
if err := p.fetchJWKS(context.Background()); err != nil {
t.Fatalf("fetchJWKS: %v", err)
}
if got := plainHits.Load(); got != 0 {
t.Fatalf("keys were fetched over http %d time(s)", got)
}
if got := ownKeyHits.Load(); got != 1 {
t.Fatalf("expected the issuer's own https jwks to be used, got %d hits", got)
}
}
// An https key fetch redirected to plain http fails rather than follow it.
func TestAnHTTPSKeyFetchIsNotRedirectedToHTTP(t *testing.T) {
plain, plainHits := plainKeyServer(t)
mux := http.NewServeMux()
mux.HandleFunc("/.well-known/openid-configuration", http.NotFound)
mux.HandleFunc("/.well-known/jwks.json", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, plain.URL+"/jwks", http.StatusFound)
})
_, p := httpsIssuer(t, mux)
if err := p.fetchJWKS(context.Background()); err == nil {
t.Fatal("fetchJWKS followed a redirect to http")
}
if got := plainHits.Load(); got != 0 {
t.Fatalf("keys were fetched over http %d time(s)", got)
}
}
+26 -3
View File
@@ -318,10 +318,25 @@ type JWK struct {
func NewOIDCProvider(name string) *OIDCProvider {
return &OIDCProvider{
name: name,
httpClient: &http.Client{Timeout: 30 * time.Second},
httpClient: &http.Client{Timeout: 30 * time.Second, CheckRedirect: refuseDowngradeRedirect},
}
}
// refuseDowngradeRedirect is the redirect policy of the client that fetches
// discovery documents and signing keys: a request that began over https may
// not be redirected to plain http, where anyone on the network path could
// substitute the keys and mint tokens STS accepts. It otherwise keeps
// net/http's default limit of 10 redirects.
func refuseDowngradeRedirect(req *http.Request, via []*http.Request) error {
if len(via) >= 10 {
return errors.New("stopped after 10 redirects")
}
if via[0].URL.Scheme == "https" && req.URL.Scheme != "https" {
return fmt.Errorf("refusing redirect from %s to non-https %s", via[0].URL.Redacted(), req.URL.Redacted())
}
return nil
}
// Name returns the provider name
func (p *OIDCProvider) Name() string {
return p.name
@@ -409,8 +424,9 @@ func (p *OIDCProvider) Initialize(config interface{}) error {
TLSClientConfig: tlsConfig,
}
p.httpClient = &http.Client{
Timeout: 30 * time.Second,
Transport: transport,
Timeout: 30 * time.Second,
Transport: transport,
CheckRedirect: refuseDowngradeRedirect,
}
// For testing, we'll skip the actual OIDC client initialization
@@ -971,6 +987,13 @@ func (p *OIDCProvider) fetchDiscoveryJWKSUri(ctx context.Context, discoveryURL s
return "", fmt.Errorf("discovery issuer %q does not match configured issuer %q", doc.Issuer, p.config.Issuer)
}
// An https issuer's keys must come over https too; otherwise the issuer's
// TLS protects nothing. Refused here, discovery falls back to the
// issuer's own /.well-known/jwks.json.
if strings.HasPrefix(p.config.Issuer, "https://") && !strings.HasPrefix(doc.JWKSUri, "https://") {
return "", fmt.Errorf("discovery jwks_uri %q is not https for https issuer %q", doc.JWKSUri, p.config.Issuer)
}
return doc.JWKSUri, nil
}
+107
View File
@@ -38,6 +38,20 @@ service SeaweedIdentityAccessManagement {
rpc GetServiceAccount (GetServiceAccountRequest) returns (GetServiceAccountResponse);
rpc ListServiceAccounts (ListServiceAccountsRequest) returns (ListServiceAccountsResponse);
rpc GetServiceAccountByAccessKey (GetServiceAccountByAccessKeyRequest) returns (GetServiceAccountByAccessKeyResponse);
// OIDC Provider Management. Writes the records the S3 servers' STS trusts
// when they run with "oidcProviderStore": {"storeType": "filer"}.
rpc PutOIDCProvider (PutOIDCProviderRequest) returns (PutOIDCProviderResponse);
rpc GetOIDCProvider (GetOIDCProviderRequest) returns (GetOIDCProviderResponse);
rpc DeleteOIDCProvider (DeleteOIDCProviderRequest) returns (DeleteOIDCProviderResponse);
rpc ListOIDCProviders (ListOIDCProvidersRequest) returns (ListOIDCProvidersResponse);
// Role Management. Writes the roles the S3 servers' STS assumes when they
// run with "roleStore": {"storeType": "filer"}.
rpc PutRole (PutRoleRequest) returns (PutRoleResponse);
rpc GetRole (GetRoleRequest) returns (GetRoleResponse);
rpc DeleteRole (DeleteRoleRequest) returns (DeleteRoleResponse);
rpc ListRoles (ListRolesRequest) returns (ListRolesResponse);
}
//////////////////////////////////////////////////
@@ -306,6 +320,99 @@ message GetServiceAccountByAccessKeyResponse {
}
//////////////////////////////////////////////////
// OIDC Provider Messages
message OIDCProvider {
string issuer_url = 1;
repeated string client_ids = 2;
repeated string thumbprints = 3;
// account_id scopes the provider; empty means global. The ARN is derived
// from it and the issuer URL.
string account_id = 4;
string arn = 5; // output only
}
// PutOIDCProviderRequest creates the provider, or replaces the client IDs and
// thumbprints of an existing one.
message PutOIDCProviderRequest {
string issuer_url = 1;
repeated string client_ids = 2;
repeated string thumbprints = 3;
string account_id = 4;
}
message PutOIDCProviderResponse {
string arn = 1;
}
message GetOIDCProviderRequest {
string issuer_url = 1;
string account_id = 2;
}
message GetOIDCProviderResponse {
OIDCProvider provider = 1;
}
message DeleteOIDCProviderRequest {
string issuer_url = 1;
string account_id = 2;
}
message DeleteOIDCProviderResponse {
}
message ListOIDCProvidersRequest {
}
message ListOIDCProvidersResponse {
repeated OIDCProvider providers = 1;
}
//////////////////////////////////////////////////
// Role Messages
message Role {
string role_name = 1;
string role_arn = 2; // defaults to arn:aws:iam::role/<role_name>
string trust_policy = 3; // JSON trust policy document
repeated string attached_policies = 4; // managed policy names
string description = 5;
int64 max_session_duration = 6; // seconds; 0 uses the STS default
}
// PutRoleRequest creates the role or replaces it.
message PutRoleRequest {
Role role = 1;
}
message PutRoleResponse {
string role_arn = 1;
}
message GetRoleRequest {
string role_name = 1;
}
message GetRoleResponse {
Role role = 1;
}
message DeleteRoleRequest {
string role_name = 1;
}
message DeleteRoleResponse {
}
message ListRolesRequest {
}
message ListRolesResponse {
repeated Role roles = 1;
}
//////////////////////////////////////////////////
// S3 IAM Cache Management
// Designed for unidirectional propagation from Filer to S3 Servers
+1065 -84
View File
File diff suppressed because it is too large. Load diff
+312
View File
@@ -39,6 +39,14 @@ const (
SeaweedIdentityAccessManagement_GetServiceAccount_FullMethodName = "/iam_pb.SeaweedIdentityAccessManagement/GetServiceAccount"
SeaweedIdentityAccessManagement_ListServiceAccounts_FullMethodName = "/iam_pb.SeaweedIdentityAccessManagement/ListServiceAccounts"
SeaweedIdentityAccessManagement_GetServiceAccountByAccessKey_FullMethodName = "/iam_pb.SeaweedIdentityAccessManagement/GetServiceAccountByAccessKey"
SeaweedIdentityAccessManagement_PutOIDCProvider_FullMethodName = "/iam_pb.SeaweedIdentityAccessManagement/PutOIDCProvider"
SeaweedIdentityAccessManagement_GetOIDCProvider_FullMethodName = "/iam_pb.SeaweedIdentityAccessManagement/GetOIDCProvider"
SeaweedIdentityAccessManagement_DeleteOIDCProvider_FullMethodName = "/iam_pb.SeaweedIdentityAccessManagement/DeleteOIDCProvider"
SeaweedIdentityAccessManagement_ListOIDCProviders_FullMethodName = "/iam_pb.SeaweedIdentityAccessManagement/ListOIDCProviders"
SeaweedIdentityAccessManagement_PutRole_FullMethodName = "/iam_pb.SeaweedIdentityAccessManagement/PutRole"
SeaweedIdentityAccessManagement_GetRole_FullMethodName = "/iam_pb.SeaweedIdentityAccessManagement/GetRole"
SeaweedIdentityAccessManagement_DeleteRole_FullMethodName = "/iam_pb.SeaweedIdentityAccessManagement/DeleteRole"
SeaweedIdentityAccessManagement_ListRoles_FullMethodName = "/iam_pb.SeaweedIdentityAccessManagement/ListRoles"
)
// SeaweedIdentityAccessManagementClient is the client API for SeaweedIdentityAccessManagement service.
@@ -70,6 +78,18 @@ type SeaweedIdentityAccessManagementClient interface {
GetServiceAccount(ctx context.Context, in *GetServiceAccountRequest, opts ...grpc.CallOption) (*GetServiceAccountResponse, error)
ListServiceAccounts(ctx context.Context, in *ListServiceAccountsRequest, opts ...grpc.CallOption) (*ListServiceAccountsResponse, error)
GetServiceAccountByAccessKey(ctx context.Context, in *GetServiceAccountByAccessKeyRequest, opts ...grpc.CallOption) (*GetServiceAccountByAccessKeyResponse, error)
// OIDC Provider Management. Writes the records the S3 servers' STS trusts
// when they run with "oidcProviderStore": {"storeType": "filer"}.
PutOIDCProvider(ctx context.Context, in *PutOIDCProviderRequest, opts ...grpc.CallOption) (*PutOIDCProviderResponse, error)
GetOIDCProvider(ctx context.Context, in *GetOIDCProviderRequest, opts ...grpc.CallOption) (*GetOIDCProviderResponse, error)
DeleteOIDCProvider(ctx context.Context, in *DeleteOIDCProviderRequest, opts ...grpc.CallOption) (*DeleteOIDCProviderResponse, error)
ListOIDCProviders(ctx context.Context, in *ListOIDCProvidersRequest, opts ...grpc.CallOption) (*ListOIDCProvidersResponse, error)
// Role Management. Writes the roles the S3 servers' STS assumes when they
// run with "roleStore": {"storeType": "filer"}.
PutRole(ctx context.Context, in *PutRoleRequest, opts ...grpc.CallOption) (*PutRoleResponse, error)
GetRole(ctx context.Context, in *GetRoleRequest, opts ...grpc.CallOption) (*GetRoleResponse, error)
DeleteRole(ctx context.Context, in *DeleteRoleRequest, opts ...grpc.CallOption) (*DeleteRoleResponse, error)
ListRoles(ctx context.Context, in *ListRolesRequest, opts ...grpc.CallOption) (*ListRolesResponse, error)
}
type seaweedIdentityAccessManagementClient struct {
@@ -280,6 +300,86 @@ func (c *seaweedIdentityAccessManagementClient) GetServiceAccountByAccessKey(ctx
return out, nil
}
func (c *seaweedIdentityAccessManagementClient) PutOIDCProvider(ctx context.Context, in *PutOIDCProviderRequest, opts ...grpc.CallOption) (*PutOIDCProviderResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(PutOIDCProviderResponse)
err := c.cc.Invoke(ctx, SeaweedIdentityAccessManagement_PutOIDCProvider_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *seaweedIdentityAccessManagementClient) GetOIDCProvider(ctx context.Context, in *GetOIDCProviderRequest, opts ...grpc.CallOption) (*GetOIDCProviderResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetOIDCProviderResponse)
err := c.cc.Invoke(ctx, SeaweedIdentityAccessManagement_GetOIDCProvider_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *seaweedIdentityAccessManagementClient) DeleteOIDCProvider(ctx context.Context, in *DeleteOIDCProviderRequest, opts ...grpc.CallOption) (*DeleteOIDCProviderResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(DeleteOIDCProviderResponse)
err := c.cc.Invoke(ctx, SeaweedIdentityAccessManagement_DeleteOIDCProvider_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *seaweedIdentityAccessManagementClient) ListOIDCProviders(ctx context.Context, in *ListOIDCProvidersRequest, opts ...grpc.CallOption) (*ListOIDCProvidersResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListOIDCProvidersResponse)
err := c.cc.Invoke(ctx, SeaweedIdentityAccessManagement_ListOIDCProviders_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *seaweedIdentityAccessManagementClient) PutRole(ctx context.Context, in *PutRoleRequest, opts ...grpc.CallOption) (*PutRoleResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(PutRoleResponse)
err := c.cc.Invoke(ctx, SeaweedIdentityAccessManagement_PutRole_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *seaweedIdentityAccessManagementClient) GetRole(ctx context.Context, in *GetRoleRequest, opts ...grpc.CallOption) (*GetRoleResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetRoleResponse)
err := c.cc.Invoke(ctx, SeaweedIdentityAccessManagement_GetRole_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *seaweedIdentityAccessManagementClient) DeleteRole(ctx context.Context, in *DeleteRoleRequest, opts ...grpc.CallOption) (*DeleteRoleResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(DeleteRoleResponse)
err := c.cc.Invoke(ctx, SeaweedIdentityAccessManagement_DeleteRole_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *seaweedIdentityAccessManagementClient) ListRoles(ctx context.Context, in *ListRolesRequest, opts ...grpc.CallOption) (*ListRolesResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListRolesResponse)
err := c.cc.Invoke(ctx, SeaweedIdentityAccessManagement_ListRoles_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// SeaweedIdentityAccessManagementServer is the server API for SeaweedIdentityAccessManagement service.
// All implementations must embed UnimplementedSeaweedIdentityAccessManagementServer
// for forward compatibility.
@@ -309,6 +409,18 @@ type SeaweedIdentityAccessManagementServer interface {
GetServiceAccount(context.Context, *GetServiceAccountRequest) (*GetServiceAccountResponse, error)
ListServiceAccounts(context.Context, *ListServiceAccountsRequest) (*ListServiceAccountsResponse, error)
GetServiceAccountByAccessKey(context.Context, *GetServiceAccountByAccessKeyRequest) (*GetServiceAccountByAccessKeyResponse, error)
// OIDC Provider Management. Writes the records the S3 servers' STS trusts
// when they run with "oidcProviderStore": {"storeType": "filer"}.
PutOIDCProvider(context.Context, *PutOIDCProviderRequest) (*PutOIDCProviderResponse, error)
GetOIDCProvider(context.Context, *GetOIDCProviderRequest) (*GetOIDCProviderResponse, error)
DeleteOIDCProvider(context.Context, *DeleteOIDCProviderRequest) (*DeleteOIDCProviderResponse, error)
ListOIDCProviders(context.Context, *ListOIDCProvidersRequest) (*ListOIDCProvidersResponse, error)
// Role Management. Writes the roles the S3 servers' STS assumes when they
// run with "roleStore": {"storeType": "filer"}.
PutRole(context.Context, *PutRoleRequest) (*PutRoleResponse, error)
GetRole(context.Context, *GetRoleRequest) (*GetRoleResponse, error)
DeleteRole(context.Context, *DeleteRoleRequest) (*DeleteRoleResponse, error)
ListRoles(context.Context, *ListRolesRequest) (*ListRolesResponse, error)
mustEmbedUnimplementedSeaweedIdentityAccessManagementServer()
}
@@ -379,6 +491,30 @@ func (UnimplementedSeaweedIdentityAccessManagementServer) ListServiceAccounts(co
func (UnimplementedSeaweedIdentityAccessManagementServer) GetServiceAccountByAccessKey(context.Context, *GetServiceAccountByAccessKeyRequest) (*GetServiceAccountByAccessKeyResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetServiceAccountByAccessKey not implemented")
}
func (UnimplementedSeaweedIdentityAccessManagementServer) PutOIDCProvider(context.Context, *PutOIDCProviderRequest) (*PutOIDCProviderResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method PutOIDCProvider not implemented")
}
func (UnimplementedSeaweedIdentityAccessManagementServer) GetOIDCProvider(context.Context, *GetOIDCProviderRequest) (*GetOIDCProviderResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetOIDCProvider not implemented")
}
func (UnimplementedSeaweedIdentityAccessManagementServer) DeleteOIDCProvider(context.Context, *DeleteOIDCProviderRequest) (*DeleteOIDCProviderResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method DeleteOIDCProvider not implemented")
}
func (UnimplementedSeaweedIdentityAccessManagementServer) ListOIDCProviders(context.Context, *ListOIDCProvidersRequest) (*ListOIDCProvidersResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListOIDCProviders not implemented")
}
func (UnimplementedSeaweedIdentityAccessManagementServer) PutRole(context.Context, *PutRoleRequest) (*PutRoleResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method PutRole not implemented")
}
func (UnimplementedSeaweedIdentityAccessManagementServer) GetRole(context.Context, *GetRoleRequest) (*GetRoleResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetRole not implemented")
}
func (UnimplementedSeaweedIdentityAccessManagementServer) DeleteRole(context.Context, *DeleteRoleRequest) (*DeleteRoleResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method DeleteRole not implemented")
}
func (UnimplementedSeaweedIdentityAccessManagementServer) ListRoles(context.Context, *ListRolesRequest) (*ListRolesResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListRoles not implemented")
}
func (UnimplementedSeaweedIdentityAccessManagementServer) mustEmbedUnimplementedSeaweedIdentityAccessManagementServer() {
}
func (UnimplementedSeaweedIdentityAccessManagementServer) testEmbeddedByValue() {}
@@ -761,6 +897,150 @@ func _SeaweedIdentityAccessManagement_GetServiceAccountByAccessKey_Handler(srv i
return interceptor(ctx, in, info, handler)
}
func _SeaweedIdentityAccessManagement_PutOIDCProvider_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(PutOIDCProviderRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(SeaweedIdentityAccessManagementServer).PutOIDCProvider(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: SeaweedIdentityAccessManagement_PutOIDCProvider_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(SeaweedIdentityAccessManagementServer).PutOIDCProvider(ctx, req.(*PutOIDCProviderRequest))
}
return interceptor(ctx, in, info, handler)
}
func _SeaweedIdentityAccessManagement_GetOIDCProvider_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetOIDCProviderRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(SeaweedIdentityAccessManagementServer).GetOIDCProvider(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: SeaweedIdentityAccessManagement_GetOIDCProvider_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(SeaweedIdentityAccessManagementServer).GetOIDCProvider(ctx, req.(*GetOIDCProviderRequest))
}
return interceptor(ctx, in, info, handler)
}
func _SeaweedIdentityAccessManagement_DeleteOIDCProvider_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(DeleteOIDCProviderRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(SeaweedIdentityAccessManagementServer).DeleteOIDCProvider(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: SeaweedIdentityAccessManagement_DeleteOIDCProvider_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(SeaweedIdentityAccessManagementServer).DeleteOIDCProvider(ctx, req.(*DeleteOIDCProviderRequest))
}
return interceptor(ctx, in, info, handler)
}
func _SeaweedIdentityAccessManagement_ListOIDCProviders_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListOIDCProvidersRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(SeaweedIdentityAccessManagementServer).ListOIDCProviders(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: SeaweedIdentityAccessManagement_ListOIDCProviders_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(SeaweedIdentityAccessManagementServer).ListOIDCProviders(ctx, req.(*ListOIDCProvidersRequest))
}
return interceptor(ctx, in, info, handler)
}
func _SeaweedIdentityAccessManagement_PutRole_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(PutRoleRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(SeaweedIdentityAccessManagementServer).PutRole(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: SeaweedIdentityAccessManagement_PutRole_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(SeaweedIdentityAccessManagementServer).PutRole(ctx, req.(*PutRoleRequest))
}
return interceptor(ctx, in, info, handler)
}
func _SeaweedIdentityAccessManagement_GetRole_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetRoleRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(SeaweedIdentityAccessManagementServer).GetRole(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: SeaweedIdentityAccessManagement_GetRole_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(SeaweedIdentityAccessManagementServer).GetRole(ctx, req.(*GetRoleRequest))
}
return interceptor(ctx, in, info, handler)
}
func _SeaweedIdentityAccessManagement_DeleteRole_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(DeleteRoleRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(SeaweedIdentityAccessManagementServer).DeleteRole(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: SeaweedIdentityAccessManagement_DeleteRole_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(SeaweedIdentityAccessManagementServer).DeleteRole(ctx, req.(*DeleteRoleRequest))
}
return interceptor(ctx, in, info, handler)
}
func _SeaweedIdentityAccessManagement_ListRoles_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListRolesRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(SeaweedIdentityAccessManagementServer).ListRoles(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: SeaweedIdentityAccessManagement_ListRoles_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(SeaweedIdentityAccessManagementServer).ListRoles(ctx, req.(*ListRolesRequest))
}
return interceptor(ctx, in, info, handler)
}
// SeaweedIdentityAccessManagement_ServiceDesc is the grpc.ServiceDesc for SeaweedIdentityAccessManagement service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
@@ -848,6 +1128,38 @@ var SeaweedIdentityAccessManagement_ServiceDesc = grpc.ServiceDesc{
MethodName: "GetServiceAccountByAccessKey",
Handler: _SeaweedIdentityAccessManagement_GetServiceAccountByAccessKey_Handler,
},
{
MethodName: "PutOIDCProvider",
Handler: _SeaweedIdentityAccessManagement_PutOIDCProvider_Handler,
},
{
MethodName: "GetOIDCProvider",
Handler: _SeaweedIdentityAccessManagement_GetOIDCProvider_Handler,
},
{
MethodName: "DeleteOIDCProvider",
Handler: _SeaweedIdentityAccessManagement_DeleteOIDCProvider_Handler,
},
{
MethodName: "ListOIDCProviders",
Handler: _SeaweedIdentityAccessManagement_ListOIDCProviders_Handler,
},
{
MethodName: "PutRole",
Handler: _SeaweedIdentityAccessManagement_PutRole_Handler,
},
{
MethodName: "GetRole",
Handler: _SeaweedIdentityAccessManagement_GetRole_Handler,
},
{
MethodName: "DeleteRole",
Handler: _SeaweedIdentityAccessManagement_DeleteRole_Handler,
},
{
MethodName: "ListRoles",
Handler: _SeaweedIdentityAccessManagement_ListRoles_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "iam.proto",
@@ -7,6 +7,7 @@ import (
"github.com/seaweedfs/seaweedfs/weed/credential"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/iam/integration"
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine"
"github.com/seaweedfs/seaweedfs/weed/security"
@@ -25,6 +26,8 @@ type IamGrpcServer struct {
iam_pb.UnimplementedSeaweedIdentityAccessManagementServer
credentialManager *credential.CredentialManager
adminSigningKey security.SigningKey
oidcProviderStore integration.OIDCProviderStore
roleStore integration.RoleStore
}
// NewIamGrpcServer creates a new IAM gRPC server. If adminSigningKey is empty
@@ -479,6 +482,18 @@ func (s *IamGrpcServer) DeletePolicy(ctx context.Context, req *iam_pb.DeletePoli
return nil, status.Errorf(codes.FailedPrecondition, "credential manager is not configured")
}
// Roles attach policies by name; deleting one still attached would let a
// policy created later under that name take effect on the role.
if s.roleStore != nil {
roles, err := integration.RolesAttachingPolicy(ctx, s.roleStore, req.Name)
if err != nil {
return nil, status.Errorf(codes.Unavailable, "check role attachments: %v", err)
}
if len(roles) > 0 {
return nil, status.Errorf(codes.FailedPrecondition, "policy %s is attached to role %s", req.Name, roles[0])
}
}
err := s.credentialManager.DeletePolicy(ctx, req.Name)
if err != nil {
glog.Errorf("Failed to delete policy %s: %v", req.Name, err)
@@ -0,0 +1,457 @@
package weed_server
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net"
"net/url"
"strings"
"time"
"github.com/seaweedfs/seaweedfs/weed/iam/integration"
"github.com/seaweedfs/seaweedfs/weed/iam/policy"
"github.com/seaweedfs/seaweedfs/weed/iam/utils"
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// SetSTSStores gives the IAM service the stores that S3 servers read when they
// run with a filer-typed "oidcProviderStore" and "roleStore". Records written
// here are the ones their STS trusts; the S3 servers pick up changes through
// their /etc/iam metadata subscription. Without the stores, the OIDC provider
// and role RPCs return FailedPrecondition.
func (s *IamGrpcServer) SetSTSStores(oidcProviders integration.OIDCProviderStore, roles integration.RoleStore) {
s.oidcProviderStore = oidcProviders
s.roleStore = roles
}
// errSTSRequiresAuth refuses the OIDC provider and role RPCs on a filer whose
// IAM service runs unauthenticated. Unlike users and policies, which keep the
// service's opt-in auth, these grant STS access outright: with them, anyone
// who can reach the port could register an issuer they control, create a role
// trusting it, and exchange a token for S3 credentials.
var errSTSRequiresAuth = status.Error(codes.FailedPrecondition,
"OIDC provider and role management requires admin authentication: set jwt.filer_signing.key in security.toml")
func (s *IamGrpcServer) requireOIDCProviderStore() (integration.OIDCProviderStore, error) {
if len(s.adminSigningKey) == 0 {
return nil, errSTSRequiresAuth
}
if s.oidcProviderStore == nil {
return nil, status.Error(codes.FailedPrecondition, "OIDC provider store not configured on this filer")
}
return s.oidcProviderStore, nil
}
func (s *IamGrpcServer) requireRoleStore() (integration.RoleStore, error) {
if len(s.adminSigningKey) == 0 {
return nil, errSTSRequiresAuth
}
if s.roleStore == nil {
return nil, status.Error(codes.FailedPrecondition, "role store not configured on this filer")
}
return s.roleStore, nil
}
func toPbOIDCProvider(rec *integration.OIDCProviderRecord) *iam_pb.OIDCProvider {
return &iam_pb.OIDCProvider{
IssuerUrl: rec.URL,
ClientIds: rec.ClientIDs,
Thumbprints: rec.Thumbprints,
AccountId: rec.AccountID,
Arn: rec.ARN,
}
}
// lookupOIDCProvider returns the stored record, nil when there is none, or an
// error when the store could not be read.
func lookupOIDCProvider(ctx context.Context, store integration.OIDCProviderStore, arn string) (*integration.OIDCProviderRecord, error) {
rec, err := store.GetProviderByARN(ctx, "", arn)
if errors.Is(err, integration.ErrOIDCProviderNotFound) {
return nil, nil
}
if err != nil {
return nil, status.Errorf(codes.Unavailable, "read OIDC provider %s: %v", arn, err)
}
return rec, nil
}
func (s *IamGrpcServer) PutOIDCProvider(ctx context.Context, req *iam_pb.PutOIDCProviderRequest) (*iam_pb.PutOIDCProviderResponse, error) {
if err := s.checkAdminAuth(ctx); err != nil {
return nil, err
}
store, err := s.requireOIDCProviderStore()
if err != nil {
return nil, err
}
if err := requireSecureIssuer(req.IssuerUrl); err != nil {
return nil, status.Error(codes.InvalidArgument, err.Error())
}
rec, err := integration.PrepareOIDCProviderRecord(req.AccountId, req.IssuerUrl, req.ClientIds, req.Thumbprints)
if err != nil {
return nil, status.Error(codes.InvalidArgument, err.Error())
}
// Put replaces what the request carries and keeps what it cannot. The
// store's atomic update decides which against the record as it is when
// written, so a delete racing the put is not merged back from a stale read.
err = store.UpdateProvider(ctx, "", rec.ARN, func(existing *integration.OIDCProviderRecord) (*integration.OIDCProviderRecord, error) {
next := *rec
next.ClientIDs = append([]string(nil), rec.ClientIDs...)
next.Thumbprints = append([]string(nil), rec.Thumbprints...)
now := time.Now().UTC()
next.CreatedAt, next.UpdatedAt = now, now
if existing != nil {
next.CreatedAt = existing.CreatedAt
next.Tags = existing.Tags
next.AllowedPrincipalTagKeys = existing.AllowedPrincipalTagKeys
next.PolicyClaim = existing.PolicyClaim
}
return &next, nil
})
if err != nil {
return nil, status.Errorf(codes.Unavailable, "store OIDC provider: %v", err)
}
return &iam_pb.PutOIDCProviderResponse{Arn: rec.ARN}, nil
}
// requireSecureIssuer refuses an issuer served over plain HTTP, as AWS does:
// STS fetches the issuer's signing keys from it, so over HTTP anyone on the
// network path could substitute their own and mint tokens STS accepts. A
// loopback issuer is allowed for local testing.
func requireSecureIssuer(issuerURL string) error {
u, err := url.Parse(issuerURL)
if err != nil {
return fmt.Errorf("invalid issuer URL: %w", err)
}
// STS matches a token's iss claim against the stored URL exactly, and the
// provider's ARN is derived from host and path alone: an issuer with
// userinfo, a query or a fragment would share its ARN with the bare
// issuer and match no token.
if u.User != nil || u.RawQuery != "" || u.ForceQuery || u.Fragment != "" {
return fmt.Errorf("issuer URL must not contain userinfo, a query or a fragment: %s", issuerURL)
}
switch u.Scheme {
case "https":
return nil
case "http":
host := u.Hostname()
if strings.EqualFold(host, "localhost") {
return nil
}
if ip := net.ParseIP(host); ip != nil && ip.IsLoopback() {
return nil
}
return fmt.Errorf("issuer URL must use https (http is allowed only for a loopback host): %s", issuerURL)
default:
return fmt.Errorf("issuer URL must use https: %s", issuerURL)
}
}
func (s *IamGrpcServer) GetOIDCProvider(ctx context.Context, req *iam_pb.GetOIDCProviderRequest) (*iam_pb.GetOIDCProviderResponse, error) {
if err := s.checkAdminAuth(ctx); err != nil {
return nil, err
}
store, err := s.requireOIDCProviderStore()
if err != nil {
return nil, err
}
arn, err := integration.DeriveOIDCProviderARN(req.AccountId, req.IssuerUrl)
if err != nil {
return nil, status.Error(codes.InvalidArgument, err.Error())
}
rec, err := lookupOIDCProvider(ctx, store, arn)
if err != nil {
return nil, err
}
if rec == nil {
return nil, status.Errorf(codes.NotFound, "OIDC provider %s not found", arn)
}
return &iam_pb.GetOIDCProviderResponse{Provider: toPbOIDCProvider(rec)}, nil
}
// DeleteOIDCProvider returns NotFound for a provider that does not exist, as
// DeleteUser does for a user; callers treat that as already deleted.
func (s *IamGrpcServer) DeleteOIDCProvider(ctx context.Context, req *iam_pb.DeleteOIDCProviderRequest) (*iam_pb.DeleteOIDCProviderResponse, error) {
if err := s.checkAdminAuth(ctx); err != nil {
return nil, err
}
store, err := s.requireOIDCProviderStore()
if err != nil {
return nil, err
}
arn, err := integration.DeriveOIDCProviderARN(req.AccountId, req.IssuerUrl)
if err != nil {
return nil, status.Error(codes.InvalidArgument, err.Error())
}
// Delete the provider as this request first saw it. The store's delete is
// conditional (OIDCProviderStore.UpdateProvider), and a retry that finds
// the record replaced by a PutOIDCProvider in between refuses rather than
// delete the newer record: the caller decides again against it.
var seen []byte
err = store.UpdateProvider(ctx, "", arn, func(existing *integration.OIDCProviderRecord) (*integration.OIDCProviderRecord, error) {
if existing == nil {
return nil, integration.ErrOIDCProviderNotFound
}
current, err := json.Marshal(existing)
if err != nil {
return nil, err
}
if seen == nil {
seen = current
} else if !bytes.Equal(seen, current) {
return nil, errProviderReplacedDuringDelete
}
return nil, nil
})
if errors.Is(err, integration.ErrOIDCProviderNotFound) {
return nil, status.Errorf(codes.NotFound, "OIDC provider %s not found", arn)
}
if errors.Is(err, errProviderReplacedDuringDelete) {
return nil, status.Errorf(codes.Aborted, "OIDC provider %s changed while it was being deleted; retry", arn)
}
if err != nil {
return nil, status.Errorf(codes.Unavailable, "delete OIDC provider: %v", err)
}
return &iam_pb.DeleteOIDCProviderResponse{}, nil
}
func (s *IamGrpcServer) ListOIDCProviders(ctx context.Context, req *iam_pb.ListOIDCProvidersRequest) (*iam_pb.ListOIDCProvidersResponse, error) {
if err := s.checkAdminAuth(ctx); err != nil {
return nil, err
}
store, err := s.requireOIDCProviderStore()
if err != nil {
return nil, err
}
records, err := store.ListProviders(ctx, "")
if err != nil {
return nil, status.Errorf(codes.Unavailable, "list OIDC providers: %v", err)
}
resp := &iam_pb.ListOIDCProvidersResponse{}
for _, rec := range records {
resp.Providers = append(resp.Providers, toPbOIDCProvider(rec))
}
return resp, nil
}
func toPbRole(role *integration.RoleDefinition) (*iam_pb.Role, error) {
out := &iam_pb.Role{
RoleName: role.RoleName,
RoleArn: role.RoleArn,
AttachedPolicies: role.AttachedPolicies,
Description: role.Description,
MaxSessionDuration: role.MaxSessionDuration,
}
if role.TrustPolicy != nil {
doc, err := json.Marshal(role.TrustPolicy)
if err != nil {
return nil, status.Errorf(codes.Internal, "encode trust policy of role %s: %v", role.RoleName, err)
}
out.TrustPolicy = string(doc)
}
return out, nil
}
// lookupRole returns the stored role, nil when there is none, or an error when
// the store could not be read.
func lookupRole(ctx context.Context, store integration.RoleStore, name string) (*integration.RoleDefinition, error) {
role, err := store.GetRole(ctx, "", name)
if errors.Is(err, integration.ErrRoleNotFound) {
return nil, nil
}
if err != nil {
return nil, status.Errorf(codes.Unavailable, "read role %s: %v", name, err)
}
return role, nil
}
// PutRole creates or replaces a role in the filer's role store. A stored role
// takes precedence over a same-named role in an S3 server's IAM config file;
// deleting the stored one restores the config-file role.
func (s *IamGrpcServer) PutRole(ctx context.Context, req *iam_pb.PutRoleRequest) (*iam_pb.PutRoleResponse, error) {
if err := s.checkAdminAuth(ctx); err != nil {
return nil, err
}
store, err := s.requireRoleStore()
if err != nil {
return nil, err
}
in := req.GetRole()
if in == nil || in.RoleName == "" {
return nil, status.Error(codes.InvalidArgument, "role.role_name is required")
}
if err := integration.ValidateRoleName(in.RoleName); err != nil {
return nil, status.Error(codes.InvalidArgument, err.Error())
}
if len(in.AttachedPolicies) > integration.MaxManagedPoliciesPerRole {
return nil, status.Errorf(codes.InvalidArgument, "at most %d managed policies may be attached to a role", integration.MaxManagedPoliciesPerRole)
}
if in.TrustPolicy == "" {
return nil, status.Error(codes.InvalidArgument, "role.trust_policy is required")
}
var trust policy.PolicyDocument
if err := json.Unmarshal([]byte(in.TrustPolicy), &trust); err != nil {
return nil, status.Errorf(codes.InvalidArgument, "parse trust policy: %v", err)
}
// STS finds a role by the name in the ARN a caller presents, so a stored
// ARN naming another role would be honoured for neither name correctly.
if in.RoleArn != "" && utils.ExtractRoleNameFromArn(in.RoleArn) != in.RoleName {
return nil, status.Errorf(codes.InvalidArgument, "role.role_arn %s does not name role %s", in.RoleArn, in.RoleName)
}
role := &integration.RoleDefinition{
RoleName: in.RoleName,
RoleArn: in.RoleArn,
TrustPolicy: &trust,
AttachedPolicies: in.AttachedPolicies,
Description: in.Description,
MaxSessionDuration: in.MaxSessionDuration,
}
if err := integration.PrepareRoleDefinition(in.RoleName, role); err != nil {
return nil, status.Error(codes.InvalidArgument, err.Error())
}
if len(role.AttachedPolicies) > 0 && s.credentialManager == nil {
return nil, status.Errorf(codes.FailedPrecondition, "credential manager is not configured")
}
for _, name := range role.AttachedPolicies {
existing, err := s.credentialManager.GetPolicy(ctx, name)
if err != nil {
return nil, status.Errorf(codes.Unavailable, "read policy %s: %v", name, err)
}
if existing == nil {
return nil, status.Errorf(codes.NotFound, "attached policy %s not found", name)
}
}
// A replaced role keeps its ID, so its sessions stay valid; a role created
// anew — including after a delete — gets a new one, so sessions of an
// earlier role of the same name do not carry over. The store's atomic
// update decides which, against the role as it is when written: a Put
// racing a DeleteRole cannot write the deleted role back with its old ID.
err = store.UpdateRole(ctx, "", in.RoleName, func(existing *integration.RoleDefinition) (*integration.RoleDefinition, error) {
next := *role
next.CreatedAt = time.Now().UTC()
next.RoleId = integration.NewRoleID()
if existing != nil {
next.CreatedAt = existing.CreatedAt
if existing.RoleId != "" {
next.RoleId = existing.RoleId
}
}
return &next, nil
})
if err != nil {
return nil, status.Errorf(codes.Unavailable, "store role: %v", err)
}
return &iam_pb.PutRoleResponse{RoleArn: role.RoleArn}, nil
}
func (s *IamGrpcServer) GetRole(ctx context.Context, req *iam_pb.GetRoleRequest) (*iam_pb.GetRoleResponse, error) {
if err := s.checkAdminAuth(ctx); err != nil {
return nil, err
}
store, err := s.requireRoleStore()
if err != nil {
return nil, err
}
if req.RoleName == "" {
return nil, status.Error(codes.InvalidArgument, "role_name is required")
}
role, err := lookupRole(ctx, store, req.RoleName)
if err != nil {
return nil, err
}
if role == nil {
return nil, status.Errorf(codes.NotFound, "role %s not found", req.RoleName)
}
out, err := toPbRole(role)
if err != nil {
return nil, err
}
return &iam_pb.GetRoleResponse{Role: out}, nil
}
// DeleteRole returns NotFound for a role that does not exist, like
// DeleteOIDCProvider. Unlike the IAM API's DeleteRole it does not require the
// policies to be detached first: this API is declarative, and the role and its
// attachments are one object here.
// errRoleReplacedDuringDelete aborts a DeleteRole whose role was replaced
// between the request's read and its delete. errProviderReplacedDuringDelete
// is the same for a DeleteOIDCProvider.
var errRoleReplacedDuringDelete = errors.New("role replaced during delete")
var errProviderReplacedDuringDelete = errors.New("OIDC provider replaced during delete")
func (s *IamGrpcServer) DeleteRole(ctx context.Context, req *iam_pb.DeleteRoleRequest) (*iam_pb.DeleteRoleResponse, error) {
if err := s.checkAdminAuth(ctx); err != nil {
return nil, err
}
store, err := s.requireRoleStore()
if err != nil {
return nil, err
}
if req.RoleName == "" {
return nil, status.Error(codes.InvalidArgument, "role_name is required")
}
// Delete the role as this request first saw it. The store's delete is
// conditional (RoleStore.UpdateRole), and a retry that finds the role
// replaced by a PutRole in between refuses rather than delete the newer
// definition: the caller decides again against it.
var seen []byte
err = store.UpdateRole(ctx, "", req.RoleName, func(existing *integration.RoleDefinition) (*integration.RoleDefinition, error) {
if existing == nil {
return nil, integration.ErrRoleNotFound
}
current, err := json.Marshal(existing)
if err != nil {
return nil, err
}
if seen == nil {
seen = current
} else if !bytes.Equal(seen, current) {
return nil, errRoleReplacedDuringDelete
}
return nil, nil
})
if errors.Is(err, integration.ErrRoleNotFound) {
return nil, status.Errorf(codes.NotFound, "role %s not found", req.RoleName)
}
if errors.Is(err, errRoleReplacedDuringDelete) {
return nil, status.Errorf(codes.Aborted, "role %s was replaced while it was being deleted; retry", req.RoleName)
}
if err != nil {
return nil, status.Errorf(codes.Unavailable, "delete role: %v", err)
}
return &iam_pb.DeleteRoleResponse{}, nil
}
func (s *IamGrpcServer) ListRoles(ctx context.Context, req *iam_pb.ListRolesRequest) (*iam_pb.ListRolesResponse, error) {
if err := s.checkAdminAuth(ctx); err != nil {
return nil, err
}
store, err := s.requireRoleStore()
if err != nil {
return nil, err
}
names, err := store.ListRoles(ctx, "")
if err != nil {
return nil, status.Errorf(codes.Unavailable, "list roles: %v", err)
}
resp := &iam_pb.ListRolesResponse{}
for _, name := range names {
role, err := lookupRole(ctx, store, name)
if err != nil {
return nil, err
}
if role == nil {
continue // deleted between list and read
}
out, err := toPbRole(role)
if err != nil {
return nil, err
}
resp.Roles = append(resp.Roles, out)
}
return resp, nil
}
@@ -0,0 +1,499 @@
package weed_server
import (
"context"
"errors"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/weed/credential"
"github.com/seaweedfs/seaweedfs/weed/iam/integration"
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine"
"github.com/seaweedfs/seaweedfs/weed/security"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
const stsTestTrust = `{"Version":"2012-10-17","Statement":[{"Effect":"Allow",` +
`"Principal":{"Federated":"https://oidc.example"},"Action":["sts:AssumeRoleWithWebIdentity"],` +
`"Condition":{"StringEquals":{"oidc:sub":"spiffe://example.org/ns/app/sa/app"}}}]}`
func newSTSTestServer(t *testing.T) (*IamGrpcServer, context.Context, *integration.MemoryOIDCProviderStore, *integration.MemoryRoleStore) {
t.Helper()
s := newTestIamGrpcServer(t)
providers, roles := integration.NewMemoryOIDCProviderStore(), integration.NewMemoryRoleStore()
s.SetSTSStores(providers, roles)
doc := policy_engine.PolicyDocument{Version: "2012-10-17", Statement: []policy_engine.PolicyStatement{{
Effect: policy_engine.PolicyEffectAllow,
Action: policy_engine.NewStringOrStringSlice("s3:GetObject"),
Resource: policy_engine.NewStringOrStringSlicePtr("arn:aws:s3:::bucket/*"),
}}}
require.NoError(t, s.credentialManager.CreatePolicy(context.Background(), "read-bucket", doc))
ctx := ctxWithBearer(string(security.GenJwtForFilerAdmin(security.SigningKey(testIamSigningKey), 60)))
return s, ctx, providers, roles
}
func requireCode(t *testing.T, err error, code codes.Code) {
t.Helper()
require.Error(t, err, "expected %s", code)
assert.Equal(t, code, status.Code(err), "error: %v", err)
}
func TestIamGrpc_OIDCProviderPutGetListDelete(t *testing.T) {
s, ctx, _, _ := newSTSTestServer(t)
put, err := s.PutOIDCProvider(ctx, &iam_pb.PutOIDCProviderRequest{
IssuerUrl: "https://oidc.example", ClientIds: []string{"aud"}, AccountId: "111122223333",
})
require.NoError(t, err)
assert.Equal(t, "arn:aws:iam::111122223333:oidc-provider/oidc.example", put.Arn)
got, err := s.GetOIDCProvider(ctx, &iam_pb.GetOIDCProviderRequest{IssuerUrl: "https://oidc.example", AccountId: "111122223333"})
require.NoError(t, err)
assert.Equal(t, []string{"aud"}, got.Provider.ClientIds)
list, err := s.ListOIDCProviders(ctx, &iam_pb.ListOIDCProvidersRequest{})
require.NoError(t, err)
require.Len(t, list.Providers, 1)
_, err = s.DeleteOIDCProvider(ctx, &iam_pb.DeleteOIDCProviderRequest{IssuerUrl: "https://oidc.example", AccountId: "111122223333"})
require.NoError(t, err)
_, err = s.GetOIDCProvider(ctx, &iam_pb.GetOIDCProviderRequest{IssuerUrl: "https://oidc.example", AccountId: "111122223333"})
requireCode(t, err, codes.NotFound)
_, err = s.DeleteOIDCProvider(ctx, &iam_pb.DeleteOIDCProviderRequest{IssuerUrl: "https://oidc.example", AccountId: "111122223333"})
requireCode(t, err, codes.NotFound)
}
// Put replaces what the request carries and keeps what it cannot express.
func TestIamGrpc_PutOIDCProviderKeepsWhatTheRequestCannotCarry(t *testing.T) {
s, ctx, providers, _ := newSTSTestServer(t)
created := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
arn, err := integration.DeriveOIDCProviderARN("", "https://oidc.example")
require.NoError(t, err)
require.NoError(t, providers.StoreProvider(context.Background(), "", &integration.OIDCProviderRecord{
ARN: arn, URL: "https://oidc.example", ClientIDs: []string{"old"},
Tags: map[string]string{"team": "infra"}, PolicyClaim: "policy", CreatedAt: created,
}))
_, err = s.PutOIDCProvider(ctx, &iam_pb.PutOIDCProviderRequest{IssuerUrl: "https://oidc.example", ClientIds: []string{"new"}})
require.NoError(t, err)
rec, err := providers.GetProviderByARN(context.Background(), "", arn)
require.NoError(t, err)
assert.Equal(t, []string{"new"}, rec.ClientIDs)
assert.Equal(t, map[string]string{"team": "infra"}, rec.Tags)
assert.Equal(t, "policy", rec.PolicyClaim)
assert.True(t, rec.CreatedAt.Equal(created))
}
func TestIamGrpc_RolePutGetListDelete(t *testing.T) {
s, ctx, _, roles := newSTSTestServer(t)
put, err := s.PutRole(ctx, &iam_pb.PutRoleRequest{Role: &iam_pb.Role{
RoleName: "app", TrustPolicy: stsTestTrust, AttachedPolicies: []string{"read-bucket"}, MaxSessionDuration: 3600,
}})
require.NoError(t, err)
assert.Equal(t, "arn:aws:iam::role/app", put.RoleArn)
stored, err := roles.GetRole(context.Background(), "", "app")
require.NoError(t, err)
assert.Equal(t, []string{"read-bucket"}, stored.AttachedPolicies)
assert.False(t, stored.CreatedAt.IsZero())
got, err := s.GetRole(ctx, &iam_pb.GetRoleRequest{RoleName: "app"})
require.NoError(t, err)
assert.Contains(t, got.Role.TrustPolicy, "spiffe://example.org/ns/app/sa/app")
// Replacing keeps CreatedAt.
_, err = s.PutRole(ctx, &iam_pb.PutRoleRequest{Role: &iam_pb.Role{RoleName: "app", TrustPolicy: stsTestTrust}})
require.NoError(t, err)
replaced, err := roles.GetRole(context.Background(), "", "app")
require.NoError(t, err)
assert.True(t, replaced.CreatedAt.Equal(stored.CreatedAt))
assert.Empty(t, replaced.AttachedPolicies)
assert.NotEmpty(t, stored.RoleId, "a created role has an ID")
assert.Equal(t, stored.RoleId, replaced.RoleId, "replacing a role changed its ID")
list, err := s.ListRoles(ctx, &iam_pb.ListRolesRequest{})
require.NoError(t, err)
require.Len(t, list.Roles, 1)
_, err = s.DeleteRole(ctx, &iam_pb.DeleteRoleRequest{RoleName: "app"})
require.NoError(t, err)
_, err = s.GetRole(ctx, &iam_pb.GetRoleRequest{RoleName: "app"})
requireCode(t, err, codes.NotFound)
_, err = s.DeleteRole(ctx, &iam_pb.DeleteRoleRequest{RoleName: "app"})
requireCode(t, err, codes.NotFound)
_, err = s.PutRole(ctx, &iam_pb.PutRoleRequest{Role: &iam_pb.Role{RoleName: "app", TrustPolicy: stsTestTrust}})
require.NoError(t, err)
recreated, err := roles.GetRole(context.Background(), "", "app")
require.NoError(t, err)
assert.NotEqual(t, stored.RoleId, recreated.RoleId, "a role created again under a deleted role's name reuses its ID")
}
func TestIamGrpc_STSRefusals(t *testing.T) {
s, ctx, _, _ := newSTSTestServer(t)
cases := []struct {
name string
call func() error
code codes.Code
}{
{"provider without client IDs", func() error {
_, err := s.PutOIDCProvider(ctx, &iam_pb.PutOIDCProviderRequest{IssuerUrl: "https://oidc.example"})
return err
}, codes.InvalidArgument},
{"provider with bad thumbprint", func() error {
_, err := s.PutOIDCProvider(ctx, &iam_pb.PutOIDCProviderRequest{IssuerUrl: "https://oidc.example", ClientIds: []string{"a"}, Thumbprints: []string{"nope"}})
return err
}, codes.InvalidArgument},
{"role without trust policy", func() error {
_, err := s.PutRole(ctx, &iam_pb.PutRoleRequest{Role: &iam_pb.Role{RoleName: "app"}})
return err
}, codes.InvalidArgument},
{"role with malformed trust policy", func() error {
_, err := s.PutRole(ctx, &iam_pb.PutRoleRequest{Role: &iam_pb.Role{RoleName: "app", TrustPolicy: "{"}})
return err
}, codes.InvalidArgument},
{"role with session out of bounds", func() error {
_, err := s.PutRole(ctx, &iam_pb.PutRoleRequest{Role: &iam_pb.Role{RoleName: "app", TrustPolicy: stsTestTrust, MaxSessionDuration: 60}})
return err
}, codes.InvalidArgument},
{"role name leaving the role store", func() error {
_, err := s.PutRole(ctx, &iam_pb.PutRoleRequest{Role: &iam_pb.Role{RoleName: "../identities/admin", TrustPolicy: stsTestTrust}})
return err
}, codes.InvalidArgument},
{"role over the policy quota", func() error {
policies := make([]string, integration.MaxManagedPoliciesPerRole+1)
for i := range policies {
policies[i] = "read-bucket"
}
_, err := s.PutRole(ctx, &iam_pb.PutRoleRequest{Role: &iam_pb.Role{RoleName: "app", TrustPolicy: stsTestTrust, AttachedPolicies: policies}})
return err
}, codes.InvalidArgument},
{"role whose ARN names another role", func() error {
_, err := s.PutRole(ctx, &iam_pb.PutRoleRequest{Role: &iam_pb.Role{RoleName: "app", RoleArn: "arn:aws:iam::role/admin", TrustPolicy: stsTestTrust}})
return err
}, codes.InvalidArgument},
{"role whose ARN is not a role ARN", func() error {
_, err := s.PutRole(ctx, &iam_pb.PutRoleRequest{Role: &iam_pb.Role{RoleName: "app", RoleArn: "arn:aws:iam::user/app", TrustPolicy: stsTestTrust}})
return err
}, codes.InvalidArgument},
{"role attaching a missing policy", func() error {
_, err := s.PutRole(ctx, &iam_pb.PutRoleRequest{Role: &iam_pb.Role{RoleName: "app", TrustPolicy: stsTestTrust, AttachedPolicies: []string{"nope"}}})
return err
}, codes.NotFound},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) { requireCode(t, tc.call(), tc.code) })
}
}
type unreadableProviders struct {
*integration.MemoryOIDCProviderStore
}
func (unreadableProviders) GetProviderByARN(context.Context, string, string) (*integration.OIDCProviderRecord, error) {
return nil, errors.New("lookup OIDC provider: filer unavailable")
}
// UpdateProvider reads the record first, as the filer store's does.
func (unreadableProviders) UpdateProvider(context.Context, string, string, integration.OIDCProviderUpdate) error {
return errors.New("lookup OIDC provider: filer unavailable")
}
type unreadableRoles struct{ *integration.MemoryRoleStore }
func (unreadableRoles) GetRole(context.Context, string, string) (*integration.RoleDefinition, error) {
return nil, errors.New("lookup role: filer unavailable")
}
// UpdateRole reads the role first, as the filer store's does.
func (unreadableRoles) UpdateRole(context.Context, string, string, integration.RoleUpdate) error {
return errors.New("lookup role: filer unavailable")
}
// An unreadable store is not an absent entry: Put must not write over what it
// could not see (a config-file entry included), and Delete must not report
// success.
func TestIamGrpc_UnreadableStoreIsUnavailableNotAbsent(t *testing.T) {
s, ctx, _, _ := newSTSTestServer(t)
providers, roles := unreadableProviders{integration.NewMemoryOIDCProviderStore()}, unreadableRoles{integration.NewMemoryRoleStore()}
s.SetSTSStores(providers, roles)
_, err := s.PutOIDCProvider(ctx, &iam_pb.PutOIDCProviderRequest{IssuerUrl: "https://oidc.example", ClientIds: []string{"aud"}})
requireCode(t, err, codes.Unavailable)
_, err = s.DeleteOIDCProvider(ctx, &iam_pb.DeleteOIDCProviderRequest{IssuerUrl: "https://oidc.example"})
requireCode(t, err, codes.Unavailable)
_, err = s.PutRole(ctx, &iam_pb.PutRoleRequest{Role: &iam_pb.Role{RoleName: "app", TrustPolicy: stsTestTrust}})
requireCode(t, err, codes.Unavailable)
_, err = s.DeleteRole(ctx, &iam_pb.DeleteRoleRequest{RoleName: "app"})
requireCode(t, err, codes.Unavailable)
recs, err := providers.ListProviders(context.Background(), "")
require.NoError(t, err)
assert.Empty(t, recs, "PutOIDCProvider wrote through a store it could not read")
names, err := roles.ListRoles(context.Background(), "")
require.NoError(t, err)
assert.Empty(t, names, "PutRole wrote through a store it could not read")
}
func TestIamGrpc_STSRPCsWithoutStoresAreFailedPrecondition(t *testing.T) {
s := newTestIamGrpcServer(t)
ctx := ctxWithBearer(string(security.GenJwtForFilerAdmin(security.SigningKey(testIamSigningKey), 60)))
_, err := s.ListOIDCProviders(ctx, &iam_pb.ListOIDCProvidersRequest{})
requireCode(t, err, codes.FailedPrecondition)
_, err = s.ListRoles(ctx, &iam_pb.ListRolesRequest{})
requireCode(t, err, codes.FailedPrecondition)
}
func TestIamGrpc_STSRPCsRequireAuth(t *testing.T) {
s, _, _, _ := newSTSTestServer(t)
ctx := context.Background()
calls := map[string]func() error{
"PutOIDCProvider": func() error { _, err := s.PutOIDCProvider(ctx, &iam_pb.PutOIDCProviderRequest{}); return err },
"GetOIDCProvider": func() error { _, err := s.GetOIDCProvider(ctx, &iam_pb.GetOIDCProviderRequest{}); return err },
"DeleteOIDCProvider": func() error {
_, err := s.DeleteOIDCProvider(ctx, &iam_pb.DeleteOIDCProviderRequest{})
return err
},
"ListOIDCProviders": func() error { _, err := s.ListOIDCProviders(ctx, &iam_pb.ListOIDCProvidersRequest{}); return err },
"PutRole": func() error { _, err := s.PutRole(ctx, &iam_pb.PutRoleRequest{}); return err },
"GetRole": func() error { _, err := s.GetRole(ctx, &iam_pb.GetRoleRequest{}); return err },
"DeleteRole": func() error { _, err := s.DeleteRole(ctx, &iam_pb.DeleteRoleRequest{}); return err },
"ListRoles": func() error { _, err := s.ListRoles(ctx, &iam_pb.ListRolesRequest{}); return err },
}
for name, call := range calls {
t.Run(name, func(t *testing.T) { requireCode(t, call(), codes.Unauthenticated) })
}
}
func TestIamGrpc_DeletePolicyAttachedToARoleIsRefused(t *testing.T) {
s, ctx, _, _ := newSTSTestServer(t)
_, err := s.PutRole(ctx, &iam_pb.PutRoleRequest{Role: &iam_pb.Role{
RoleName: "app", TrustPolicy: stsTestTrust, AttachedPolicies: []string{"read-bucket"},
}})
require.NoError(t, err)
_, err = s.DeletePolicy(ctx, &iam_pb.DeletePolicyRequest{Name: "read-bucket"})
requireCode(t, err, codes.FailedPrecondition)
_, err = s.PutRole(ctx, &iam_pb.PutRoleRequest{Role: &iam_pb.Role{RoleName: "app", TrustPolicy: stsTestTrust}})
require.NoError(t, err)
_, err = s.DeletePolicy(ctx, &iam_pb.DeletePolicyRequest{Name: "read-bucket"})
assert.NoError(t, err, "a policy no role attaches could not be deleted")
}
func TestIamGrpc_PutOIDCProviderRequiresHTTPSExceptOnLoopback(t *testing.T) {
s, ctx, _, _ := newSTSTestServer(t)
for issuer, ok := range map[string]bool{
"https://oidc.example": true,
"http://localhost:8080": true,
"http://127.0.0.1:18999": true,
"http://[::1]:8080": true,
"http://oidc.example": false,
"http://10.0.0.5": false,
"ftp://oidc.example": false,
"http://localhost.attacker.example": false,
"http://LOCALHOST:8080": true,
"https://oidc.example?x=1": false,
"https://oidc.example?": false,
"https://oidc.example#frag": false,
"https://user@oidc.example": false,
} {
_, err := s.PutOIDCProvider(ctx, &iam_pb.PutOIDCProviderRequest{IssuerUrl: issuer, ClientIds: []string{"aud"}})
if ok {
assert.NoError(t, err, issuer)
} else {
requireCode(t, err, codes.InvalidArgument)
}
}
}
// Without an admin signing key the IAM service accepts any caller. Users and
// policies keep that opt-in behaviour, but the OIDC provider and role RPCs
// grant STS access outright, so they refuse to run unauthenticated.
func TestIamGrpc_STSRPCsRefuseAnUnauthenticatedService(t *testing.T) {
cm, err := credential.NewCredentialManager(credential.StoreTypeMemory, nil, "")
require.NoError(t, err)
s := NewIamGrpcServer(cm, nil)
s.SetSTSStores(integration.NewMemoryOIDCProviderStore(), integration.NewMemoryRoleStore())
ctx := context.Background()
calls := map[string]func() error{
"PutOIDCProvider": func() error {
_, err := s.PutOIDCProvider(ctx, &iam_pb.PutOIDCProviderRequest{IssuerUrl: "https://oidc.example", ClientIds: []string{"aud"}})
return err
},
"GetOIDCProvider": func() error {
_, err := s.GetOIDCProvider(ctx, &iam_pb.GetOIDCProviderRequest{IssuerUrl: "https://oidc.example"})
return err
},
"DeleteOIDCProvider": func() error {
_, err := s.DeleteOIDCProvider(ctx, &iam_pb.DeleteOIDCProviderRequest{IssuerUrl: "https://oidc.example"})
return err
},
"ListOIDCProviders": func() error { _, err := s.ListOIDCProviders(ctx, &iam_pb.ListOIDCProvidersRequest{}); return err },
"PutRole": func() error {
_, err := s.PutRole(ctx, &iam_pb.PutRoleRequest{Role: &iam_pb.Role{RoleName: "app", TrustPolicy: stsTestTrust}})
return err
},
"GetRole": func() error { _, err := s.GetRole(ctx, &iam_pb.GetRoleRequest{RoleName: "app"}); return err },
"DeleteRole": func() error { _, err := s.DeleteRole(ctx, &iam_pb.DeleteRoleRequest{RoleName: "app"}); return err },
"ListRoles": func() error { _, err := s.ListRoles(ctx, &iam_pb.ListRolesRequest{}); return err },
}
for name, call := range calls {
t.Run(name, func(t *testing.T) { requireCode(t, call(), codes.FailedPrecondition) })
}
// Users keep the service's opt-in auth.
_, err = s.ListUsers(ctx, &iam_pb.ListUsersRequest{})
assert.NoError(t, err)
}
// deletedDuringPutRoles has UpdateRole behave as the filer store's does when
// a DeleteRole lands between its read and its write: the conditional write
// fails, and the update is applied again to the role as it now is — absent.
type deletedDuringPutRoles struct {
*integration.MemoryRoleStore
earlier *integration.RoleDefinition
}
// GetRole is a read made before the delete landed.
func (s deletedDuringPutRoles) GetRole(context.Context, string, string) (*integration.RoleDefinition, error) {
return s.earlier, nil
}
func (s deletedDuringPutRoles) UpdateRole(ctx context.Context, addr, name string, update integration.RoleUpdate) error {
if _, err := update(s.earlier); err != nil { // the write that loses to the delete
return err
}
next, err := update(nil)
if err != nil {
return err
}
return s.MemoryRoleStore.StoreRole(ctx, addr, name, next)
}
// A PutRole racing a DeleteRole must not write the deleted role back under
// its old ID, which would revive the deleted role's sessions.
func TestIamGrpc_PutRoleRacingADeleteDoesNotReviveTheOldRoleID(t *testing.T) {
s, ctx, providers, _ := newSTSTestServer(t)
store := deletedDuringPutRoles{MemoryRoleStore: integration.NewMemoryRoleStore(), earlier: &integration.RoleDefinition{RoleName: "app", RoleId: "AROA-DELETED"}}
s.SetSTSStores(providers, store)
_, err := s.PutRole(ctx, &iam_pb.PutRoleRequest{Role: &iam_pb.Role{RoleName: "app", TrustPolicy: stsTestTrust}})
require.NoError(t, err)
role, err := store.MemoryRoleStore.GetRole(context.Background(), "", "app")
require.NoError(t, err)
assert.NotEqual(t, "AROA-DELETED", role.RoleId, "the deleted role's ID was written back")
assert.NotEmpty(t, role.RoleId)
}
// replacedDuringDeleteRoles has UpdateRole behave as the filer store's does
// when a PutRole replaces the role between the delete's read and its write:
// the conditional delete fails and the update runs again on the replacement.
type replacedDuringDeleteRoles struct {
*integration.MemoryRoleStore
earlier *integration.RoleDefinition
}
func (s replacedDuringDeleteRoles) UpdateRole(ctx context.Context, addr, name string, update integration.RoleUpdate) error {
if _, err := update(s.earlier); err != nil { // the delete that loses to the PutRole
return err
}
return s.MemoryRoleStore.UpdateRole(ctx, addr, name, update)
}
// A DeleteRole racing a PutRole does not delete the definition the PutRole
// wrote: it is refused as Aborted, and the caller decides again.
func TestIamGrpc_DeleteRoleDoesNotDeleteARoleReplacedMeanwhile(t *testing.T) {
s, ctx, providers, _ := newSTSTestServer(t)
store := replacedDuringDeleteRoles{MemoryRoleStore: integration.NewMemoryRoleStore(), earlier: &integration.RoleDefinition{RoleName: "app", RoleId: "AROA-OLD"}}
require.NoError(t, store.StoreRole(context.Background(), "", "app", &integration.RoleDefinition{RoleName: "app", RoleId: "AROA-NEW"}))
s.SetSTSStores(providers, store)
_, err := s.DeleteRole(ctx, &iam_pb.DeleteRoleRequest{RoleName: "app"})
requireCode(t, err, codes.Aborted)
role, err := store.GetRole(context.Background(), "", "app")
require.NoError(t, err, "the replacement role was deleted")
assert.Equal(t, "AROA-NEW", role.RoleId)
}
// deletedDuringPutProviders has UpdateProvider behave as the filer store's
// does when a delete lands between its read and its write: the conditional
// write fails, and the update is applied again to the record as it now
// is — absent.
type deletedDuringPutProviders struct {
*integration.MemoryOIDCProviderStore
earlier *integration.OIDCProviderRecord
}
func (s deletedDuringPutProviders) UpdateProvider(ctx context.Context, addr, arn string, update integration.OIDCProviderUpdate) error {
if _, err := update(s.earlier); err != nil { // the write that loses to the delete
return err
}
next, err := update(nil)
if err != nil {
return err
}
return s.MemoryOIDCProviderStore.StoreProvider(ctx, addr, next)
}
// A PutOIDCProvider racing a DeleteOIDCProvider must not carry the deleted
// record's fields over to the new one — the request decides them.
func TestIamGrpc_PutOIDCProviderRacingADeleteKeepsNoOldFields(t *testing.T) {
s, ctx, _, roles := newSTSTestServer(t)
arn := "arn:aws:iam::111122223333:oidc-provider/oidc.example"
store := deletedDuringPutProviders{
MemoryOIDCProviderStore: integration.NewMemoryOIDCProviderStore(),
earlier: &integration.OIDCProviderRecord{
ARN: arn, URL: "https://oidc.example", Tags: map[string]string{"env": "deleted"}, PolicyClaim: "stale",
},
}
s.SetSTSStores(store, roles)
_, err := s.PutOIDCProvider(ctx, &iam_pb.PutOIDCProviderRequest{
IssuerUrl: "https://oidc.example", ClientIds: []string{"aud"}, AccountId: "111122223333",
})
require.NoError(t, err)
rec, err := store.MemoryOIDCProviderStore.GetProviderByARN(context.Background(), "", arn)
require.NoError(t, err)
assert.Empty(t, rec.Tags, "the deleted record's tags were carried over")
assert.Empty(t, rec.PolicyClaim, "the deleted record's policy claim was carried over")
assert.Equal(t, []string{"aud"}, rec.ClientIDs)
}
// replacedDuringDeleteProviders has UpdateProvider behave as the filer
// store's does when a PutOIDCProvider replaces the record between the
// delete's read and its write: the conditional delete fails and the update
// runs again on the replacement.
type replacedDuringDeleteProviders struct {
*integration.MemoryOIDCProviderStore
earlier *integration.OIDCProviderRecord
}
func (s replacedDuringDeleteProviders) UpdateProvider(ctx context.Context, addr, arn string, update integration.OIDCProviderUpdate) error {
if _, err := update(s.earlier); err != nil { // the delete that loses to the put
return err
}
return s.MemoryOIDCProviderStore.UpdateProvider(ctx, addr, arn, update)
}
// A DeleteOIDCProvider racing a PutOIDCProvider does not delete the record
// the put wrote: it is refused as Aborted, and the caller decides again.
func TestIamGrpc_DeleteOIDCProviderDoesNotDeleteAProviderReplacedMeanwhile(t *testing.T) {
s, ctx, _, roles := newSTSTestServer(t)
arn := "arn:aws:iam::111122223333:oidc-provider/oidc.example"
store := replacedDuringDeleteProviders{
MemoryOIDCProviderStore: integration.NewMemoryOIDCProviderStore(),
earlier: &integration.OIDCProviderRecord{ARN: arn, URL: "https://oidc.example", ClientIDs: []string{"old"}},
}
require.NoError(t, store.StoreProvider(context.Background(), "", &integration.OIDCProviderRecord{ARN: arn, URL: "https://oidc.example", ClientIDs: []string{"new"}}))
s.SetSTSStores(store, roles)
_, err := s.DeleteOIDCProvider(ctx, &iam_pb.DeleteOIDCProviderRequest{IssuerUrl: "https://oidc.example", AccountId: "111122223333"})
requireCode(t, err, codes.Aborted)
rec, err := store.GetProviderByARN(context.Background(), "", arn)
require.NoError(t, err, "the replacement record was deleted")
assert.Equal(t, []string{"new"}, rec.ClientIDs)
}