mirror of
https://github.com/versity/versitygw.git
synced 2026-08-29 12:16:56 +00:00
The S3 gateway and the standalone IAM service exchange authorization decisions over the private endpoints, where a version skew is silently unsafe in both directions: an older service drops a request field it does not know (a `Condition` block, say) and evaluates fail-open, while an older gateway ignores a response field it does not know and misses a deny the service intended. Neither side could previously detect either case. Both peers now declare a protocol version on every exchange via the `X-Vgw-Private-Protocol` header — the gateway on each request, the service on each response, error responses included — and each refuses a peer it cannot serve safely. The service rejects a gateway below `MinClientProtocol` with a `ProtocolMismatch` code; the gateway rejects a service older than the `ProtocolVersion` it speaks, and rejects a response carrying no version at all, since no build of this protocol omits the header and something else answering on that address should not be interpreted as an IAM decision. `ParseProtocolVersion` is shared by both sides and deliberately strict: an unreadable value is a mismatch, never an assumed default. A new root-signed `/private/version` endpoint reports the protocol version, the minimum client the service will serve, and the build tag (`WithPrivateServerVersion`). It is exempt from the service's own client-version check so it can still answer a gateway the service refuses — which is how that gateway learns why. Being authenticated like every other private endpoint, it also lets the gateway's startup probe verify its own credential and its mTLS transport in the same round trip. The gateway probes it once in `NewIAMServiceStandalone` rather than discovering a skew as an opaque per-request 500. An incompatible service is fatal after a 30s window, since a gateway that cannot authorize a single request is more useful refusing to start with the reason in its log; an unreachable one is only a warning, because the two processes legitimately start in parallel and every request checks the version regardless. Only conditions that can resolve on their own are retried — a rejected credential is reported immediately.
286 lines
9.6 KiB
Go
286 lines
9.6 KiB
Go
// Copyright 2023 Versity Software
|
|
// This file is licensed under the Apache License, Version 2.0
|
|
// (the "License"); you may not use this file except in compliance
|
|
// with the License. You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing,
|
|
// software distributed under the License is distributed on an
|
|
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
// KIND, either express or implied. See the License for the
|
|
// specific language governing permissions and limitations
|
|
// under the License.
|
|
|
|
package auth
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/versity/versitygw/s3err"
|
|
)
|
|
|
|
// resolveAccountsByLookup implements ResolveAccounts for backends that have
|
|
// no batch endpoint, by calling getUserAccount once per access key and
|
|
// collecting the ones that don't exist.
|
|
func resolveAccountsByLookup(accessKeyIDs []string, getUserAccount func(string) (Account, error)) ([]string, error) {
|
|
missing := []string{}
|
|
for _, access := range accessKeyIDs {
|
|
_, err := getUserAccount(access)
|
|
if err != nil {
|
|
if err == ErrNoSuchUser || err == s3err.GetAPIError(s3err.ErrAdminUserNotFound) {
|
|
missing = append(missing, access)
|
|
continue
|
|
}
|
|
if errors.Is(err, s3err.GetAPIError(s3err.ErrAdminMethodNotSupported)) {
|
|
return nil, err
|
|
}
|
|
return nil, fmt.Errorf("check user account: %w", err)
|
|
}
|
|
}
|
|
return missing, nil
|
|
}
|
|
|
|
type Role string
|
|
|
|
const (
|
|
RoleUser Role = "user"
|
|
RoleAdmin Role = "admin"
|
|
RoleUserPlus Role = "userplus"
|
|
)
|
|
|
|
func (r Role) IsValid() bool {
|
|
switch r {
|
|
case RoleAdmin:
|
|
return true
|
|
case RoleUser:
|
|
return true
|
|
case RoleUserPlus:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// Account is a gateway IAM account
|
|
type Account struct {
|
|
Access string `json:"access"`
|
|
Secret string `json:"secret"`
|
|
Role Role `json:"role"`
|
|
UserID int `json:"userID"`
|
|
GroupID int `json:"groupID"`
|
|
ProjectID int `json:"projectID"`
|
|
|
|
// SessionToken and IsSession describe a temporary credential minted by
|
|
// AssumeRoleWithWebIdentity, and are set only by the S3 auth
|
|
// middlewares for the duration of one request. They ride on Account
|
|
// rather than on auth.AccessOptions so the ~55 controller sites that
|
|
// already forward the request's Account into an authorization check
|
|
// carry them without a single edit.
|
|
//
|
|
// Both are json:"-": a session is request state, never persisted by an
|
|
// IAM backend nor echoed by the admin API.
|
|
SessionToken string `json:"-"`
|
|
IsSession bool `json:"-"`
|
|
}
|
|
|
|
// String elides the two credential-bearing fields so an Account can't leak
|
|
// them into a log line through a %v/%+v verb. debuglogger redacts the
|
|
// X-Amz-Security-Token *header*, which does nothing for a struct printed
|
|
// after the token has been parsed out of it.
|
|
func (a Account) String() string {
|
|
return fmt.Sprintf("Account{Access:%s, Secret:REDACTED, Role:%s, UserID:%d, GroupID:%d, ProjectID:%d, SessionToken:REDACTED, IsSession:%t}",
|
|
a.Access, a.Role, a.UserID, a.GroupID, a.ProjectID, a.IsSession)
|
|
}
|
|
|
|
type ListUserAccountsResult struct {
|
|
Accounts []Account
|
|
}
|
|
|
|
// Mutable props, which could be changed when updating an IAM account
|
|
type MutableProps struct {
|
|
Secret *string `json:"secret"`
|
|
Role Role `json:"role"`
|
|
UserID *int `json:"userID"`
|
|
GroupID *int `json:"groupID"`
|
|
ProjectID *int `json:"projectID"`
|
|
}
|
|
|
|
func (m MutableProps) Validate() error {
|
|
if m.Role != "" && !m.Role.IsValid() {
|
|
return s3err.GetAPIError(s3err.ErrAdminInvalidUserRole)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func updateAcc(acc *Account, props MutableProps) {
|
|
if props.Secret != nil {
|
|
acc.Secret = *props.Secret
|
|
}
|
|
if props.GroupID != nil {
|
|
acc.GroupID = *props.GroupID
|
|
}
|
|
if props.UserID != nil {
|
|
acc.UserID = *props.UserID
|
|
}
|
|
if props.ProjectID != nil {
|
|
acc.ProjectID = *props.ProjectID
|
|
}
|
|
if props.Role != "" {
|
|
acc.Role = props.Role
|
|
}
|
|
}
|
|
|
|
// IAMService is the interface for all IAM service implementations
|
|
//
|
|
//go:generate moq -out ../s3api/controllers/iam_moq_test.go -pkg controllers . IAMService
|
|
type IAMService interface {
|
|
CreateAccount(account Account) error
|
|
GetUserAccount(access string) (Account, error)
|
|
ResolveAccounts(accessKeyIDs []string) ([]string, error)
|
|
UpdateUserAccount(access string, props MutableProps) error
|
|
DeleteUserAccount(access string) error
|
|
ListUserAccounts() ([]Account, error)
|
|
Shutdown() error
|
|
}
|
|
|
|
var (
|
|
// ErrUserExists is returned when the user already exists
|
|
ErrUserExists = errors.New("user already exists")
|
|
// ErrNoSuchUser is returned when the user does not exist
|
|
ErrNoSuchUser = errors.New("user not found")
|
|
// ErrInvalidSessionToken is returned when a request's
|
|
// X-Amz-Security-Token is missing for a temporary (ASIA…) access key,
|
|
// doesn't match the session that key belongs to, or is present
|
|
// alongside a permanent credential. Callers render it as S3's
|
|
// InvalidToken, distinct from the InvalidAccessKeyId that ErrNoSuchUser
|
|
// produces — matching real S3, which reports the two separately.
|
|
ErrInvalidSessionToken = errors.New("invalid session token")
|
|
)
|
|
|
|
type Opts struct {
|
|
RootAccount Account
|
|
Dir string
|
|
LDAPServerURL string
|
|
LDAPBindDN string
|
|
LDAPPassword string
|
|
LDAPQueryBase string
|
|
LDAPObjClasses string
|
|
LDAPAccessAtr string
|
|
LDAPSecretAtr string
|
|
LDAPRoleAtr string
|
|
LDAPUserIdAtr string
|
|
LDAPGroupIdAtr string
|
|
LDAPProjectIdAtr string
|
|
LDAPTLSSkipVerify bool
|
|
VaultEndpointURL string
|
|
VaultNamespace string
|
|
VaultSecretStoragePath string
|
|
VaultSecretStorageNamespace string
|
|
VaultAuthMethod string
|
|
VaultAuthNamespace string
|
|
VaultMountPath string
|
|
VaultRootToken string
|
|
VaultRoleId string
|
|
VaultRoleSecret string
|
|
VaultServerCert string
|
|
VaultClientCert string
|
|
VaultClientCertKey string
|
|
S3Access string
|
|
S3Secret string
|
|
S3Region string
|
|
S3Bucket string
|
|
S3Endpoint string
|
|
S3DisableSSlVerfiy bool
|
|
CacheDisable bool
|
|
CacheTTL int
|
|
CachePrune int
|
|
IpaHost string
|
|
IpaVaultName string
|
|
IpaUser string
|
|
IpaPassword string
|
|
IpaInsecure bool
|
|
StandaloneIAMEndpoint string
|
|
StandaloneIAMAccess string
|
|
StandaloneIAMSecret string
|
|
StandaloneClientCert string
|
|
StandaloneClientCertKey string
|
|
StandaloneServerCA string
|
|
StandaloneDefaultUserID int
|
|
StandaloneDefaultGroupID int
|
|
StandaloneDefaultProjectID int
|
|
}
|
|
|
|
func New(o *Opts) (IAMService, error) {
|
|
var svc IAMService
|
|
var err error
|
|
|
|
switch {
|
|
case o.StandaloneIAMEndpoint != "":
|
|
fmt.Printf("initializing standalone IAM with %q\n", o.StandaloneIAMEndpoint)
|
|
svc, err = NewIAMServiceStandalone(o.RootAccount, IAMServiceStandaloneConfig{
|
|
Endpoint: o.StandaloneIAMEndpoint,
|
|
Access: o.StandaloneIAMAccess,
|
|
Secret: o.StandaloneIAMSecret,
|
|
ClientCert: o.StandaloneClientCert,
|
|
ClientCertKey: o.StandaloneClientCertKey,
|
|
ServerCA: o.StandaloneServerCA,
|
|
DefaultUserID: o.StandaloneDefaultUserID,
|
|
DefaultGroupID: o.StandaloneDefaultGroupID,
|
|
DefaultProjectID: o.StandaloneDefaultProjectID,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Never cache-wrapped, unlike every other backend below: IAMCache
|
|
// only implements the base IAMService methods, so wrapping this
|
|
// backend in it would silently strip the SigningKeyProvider/
|
|
// PolicyEvaluator interfaces signature verification and policy
|
|
// enforcement depend on — not just skip a performance
|
|
// optimization, but break both outright.
|
|
//
|
|
// TODO: Do we need to implement cache for this ?
|
|
return svc, nil
|
|
case o.Dir != "":
|
|
svc, err = NewInternal(o.RootAccount, o.Dir)
|
|
fmt.Printf("initializing internal IAM with %q\n", o.Dir)
|
|
case o.LDAPServerURL != "":
|
|
svc, err = NewLDAPService(o.RootAccount, o.LDAPServerURL, o.LDAPBindDN, o.LDAPPassword,
|
|
o.LDAPQueryBase, o.LDAPAccessAtr, o.LDAPSecretAtr, o.LDAPRoleAtr, o.LDAPUserIdAtr,
|
|
o.LDAPGroupIdAtr, o.LDAPProjectIdAtr, o.LDAPObjClasses, o.LDAPTLSSkipVerify)
|
|
fmt.Printf("initializing LDAP IAM with %q\n", o.LDAPServerURL)
|
|
case o.S3Endpoint != "":
|
|
svc, err = NewS3(o.RootAccount, o.S3Access, o.S3Secret, o.S3Region, o.S3Bucket,
|
|
o.S3Endpoint, o.S3DisableSSlVerfiy)
|
|
fmt.Printf("initializing S3 IAM with '%v/%v'\n",
|
|
o.S3Endpoint, o.S3Bucket)
|
|
case o.VaultEndpointURL != "":
|
|
svc, err = NewVaultIAMService(o.RootAccount, o.VaultEndpointURL, o.VaultNamespace, o.VaultSecretStoragePath, o.VaultSecretStorageNamespace,
|
|
o.VaultAuthMethod, o.VaultAuthNamespace, o.VaultMountPath, o.VaultRootToken, o.VaultRoleId, o.VaultRoleSecret,
|
|
o.VaultServerCert, o.VaultClientCert, o.VaultClientCertKey)
|
|
fmt.Printf("initializing Vault IAM with %q\n", o.VaultEndpointURL)
|
|
case o.IpaHost != "":
|
|
svc, err = NewIpaIAMService(o.RootAccount, o.IpaHost, o.IpaVaultName, o.IpaUser, o.IpaPassword, o.IpaInsecure)
|
|
fmt.Printf("initializing IPA IAM with %q\n", o.IpaHost)
|
|
default:
|
|
// if no iam options selected, default to the single user mode
|
|
fmt.Println("No IAM service configured, enabling single account mode")
|
|
return NewIAMServiceSingle(o.RootAccount), nil
|
|
}
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if o.CacheDisable {
|
|
return svc, nil
|
|
}
|
|
|
|
return NewCache(svc,
|
|
time.Duration(o.CacheTTL)*time.Second,
|
|
time.Duration(o.CachePrune)*time.Second), nil
|
|
}
|