mirror of
https://github.com/versity/versitygw.git
synced 2026-08-28 20:06:02 +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.
664 lines
24 KiB
Go
664 lines
24 KiB
Go
// Copyright 2026 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 (
|
|
"bytes"
|
|
"context"
|
|
"crypto/tls"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/versity/versitygw/iamapi/private"
|
|
"github.com/versity/versitygw/internal/netutil"
|
|
"github.com/versity/versitygw/internal/sigv4auth"
|
|
"github.com/versity/versitygw/s3err"
|
|
)
|
|
|
|
const (
|
|
// standaloneSigningRegion/standaloneSigningService are the SigV4
|
|
// envelope this client signs its own calls to the private endpoints with
|
|
standaloneSigningRegion = "us-east-1"
|
|
standaloneSigningService = sigv4auth.ServiceIAM
|
|
|
|
standaloneRequestTimeout = 10 * time.Second
|
|
)
|
|
|
|
// standaloneProbeWindow bounds how long NewIAMServiceStandalone waits for the
|
|
// IAM service to answer compatibly before it gives up, and
|
|
// standaloneProbeInterval how often it retries within that window. Both
|
|
// failures are retried, for different reasons: an unreachable service is the
|
|
// ordinary case of the two processes starting in parallel, and an
|
|
// incompatible one is what a gateway sees while the IAM service it is paired
|
|
// with is still rolling. Failing immediately on either would turn a routine
|
|
// deployment into a crash loop whose backoff long outlives the condition.
|
|
//
|
|
// Variables rather than constants so tests can shorten the window; nothing
|
|
// else writes them.
|
|
var (
|
|
standaloneProbeWindow = 30 * time.Second
|
|
standaloneProbeInterval = 2 * time.Second
|
|
)
|
|
|
|
// protocolMismatchError reports that whatever answered the private endpoints
|
|
// is not a standalone IAM service this gateway can use. It is a distinct type
|
|
// so the startup probe can tell an incompatible peer, which is fatal, from an
|
|
// unreachable one, which is not.
|
|
type protocolMismatchError struct {
|
|
detail string
|
|
}
|
|
|
|
func (e *protocolMismatchError) Error() string {
|
|
return "iam standalone: " + e.detail
|
|
}
|
|
|
|
// IAMServiceStandaloneConfig configures IAMServiceStandalone.
|
|
type IAMServiceStandaloneConfig struct {
|
|
// Endpoint is either a "host:port" TCP address (mTLS required -
|
|
// ClientCert/ClientCertKey/ServerCA) or a unix socket path, matching
|
|
// the standalone IAM service's own --private-ports address shape.
|
|
Endpoint string
|
|
// Access/Secret are this client's own SigV4 identity — the credential
|
|
// it signs its private requests with. Both must be set together, or
|
|
// both left empty to sign with the gateway's root account.
|
|
Access string
|
|
Secret string
|
|
// ClientCert/ClientCertKey/ServerCA configure outbound mTLS. Required
|
|
// (all three) for a TCP Endpoint; unused for a unix socket Endpoint.
|
|
ClientCert string
|
|
ClientCertKey string
|
|
ServerCA string
|
|
// DefaultUserID/GroupID/ProjectID are assigned to every resolved
|
|
// (non-root) account. The standalone IAM service's user model
|
|
// (iamapi/types.User, mirroring real AWS IAM) has no POSIX uid/gid/
|
|
// project-id concept, so there is no per-user value to fetch instead —
|
|
// every standalone-backed account shares one POSIX identity for
|
|
// backend file-ownership purposes.
|
|
DefaultUserID int
|
|
DefaultGroupID int
|
|
DefaultProjectID int
|
|
}
|
|
|
|
// IAMServiceStandalone is the S3 gateway's client for a standalone IAM
|
|
// service's private endpoints. It never holds a plaintext secret for any account
|
|
// but its own signing identity and the locally-known root account — every
|
|
// other account's secret stays inside the IAM service process.
|
|
// CreateAccount/UpdateUserAccount/ DeleteUserAccount/ListUserAccounts
|
|
// are unsupported here for the same reason: mutating a user requires setting a secret, which must never
|
|
// flow into this process — manage users via the IAM service's own control-plane API instead.
|
|
type IAMServiceStandalone struct {
|
|
client *http.Client
|
|
baseURL string
|
|
access string
|
|
secret string
|
|
rootAcc Account
|
|
cfg IAMServiceStandaloneConfig
|
|
}
|
|
|
|
var (
|
|
_ IAMService = (*IAMServiceStandalone)(nil)
|
|
_ SigningKeyProvider = (*IAMServiceStandalone)(nil)
|
|
_ PolicyEvaluator = (*IAMServiceStandalone)(nil)
|
|
)
|
|
|
|
// NewIAMServiceStandalone constructs the standalone IAM service client.
|
|
// rootAcc is the gateway's own root account — always resolved locally,
|
|
// never round-tripped through the IAM service.
|
|
func NewIAMServiceStandalone(rootAcc Account, cfg IAMServiceStandaloneConfig) (*IAMServiceStandalone, error) {
|
|
if cfg.Endpoint == "" {
|
|
return nil, fmt.Errorf("iam standalone: endpoint is required")
|
|
}
|
|
|
|
if (cfg.Access == "") != (cfg.Secret == "") {
|
|
return nil, fmt.Errorf("iam standalone: access and secret must both be set, or both left empty to sign with the root account")
|
|
}
|
|
|
|
access, secret := cfg.Access, cfg.Secret
|
|
if access == "" {
|
|
access, secret = rootAcc.Access, rootAcc.Secret
|
|
}
|
|
|
|
client, baseURL, err := newStandaloneHTTPClient(cfg)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
svc := &IAMServiceStandalone{
|
|
client: client,
|
|
baseURL: baseURL,
|
|
access: access,
|
|
secret: secret,
|
|
rootAcc: rootAcc,
|
|
cfg: cfg,
|
|
}
|
|
|
|
if err := svc.probeProtocol(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return svc, nil
|
|
}
|
|
|
|
// probeProtocol verifies at startup what every request verifies anyway, so a
|
|
// version skew is diagnosed once, here, instead of once per S3 request as an
|
|
// opaque 500. Because it is a signed request to a root-authenticated
|
|
// endpoint, reaching a compatible answer also proves the transport, the mTLS
|
|
// material, and this gateway's own IAM credential.
|
|
//
|
|
// An incompatible service is fatal: a gateway that cannot authorize a single
|
|
// request is more useful refusing to start, with the reason in its log, than
|
|
// running and serving errors. An unreachable one is only a warning — the two
|
|
// processes legitimately start in parallel, and every request checks the
|
|
// version regardless.
|
|
func (s *IAMServiceStandalone) probeProtocol() error {
|
|
deadline := time.Now().Add(standaloneProbeWindow)
|
|
|
|
for {
|
|
var resp private.VersionResponse
|
|
// The endpoint takes no arguments; an empty object is the request.
|
|
err := s.doPrivateRequest(private.VersionPath, struct{}{}, &resp)
|
|
|
|
// The version endpoint answers even a gateway the service will not
|
|
// serve — that is the whole point of exempting it from the service's
|
|
// own check — so the probe has to draw that conclusion itself from
|
|
// the minimum the service reports. Without this the one direction a
|
|
// gateway cannot detect from a response header would pass startup and
|
|
// fail on every request afterwards.
|
|
if err == nil && private.ProtocolVersion < resp.MinClient {
|
|
err = &protocolMismatchError{fmt.Sprintf(
|
|
"IAM service at %q serves private protocol %d and newer, this gateway speaks %d: upgrade the gateway",
|
|
s.cfg.Endpoint, resp.MinClient, private.ProtocolVersion)}
|
|
}
|
|
|
|
if err == nil {
|
|
serverVersion := resp.ServerVersion
|
|
if serverVersion == "" {
|
|
serverVersion = "unknown"
|
|
}
|
|
fmt.Printf("standalone IAM service %q: version %s, private protocol %d\n",
|
|
s.cfg.Endpoint, serverVersion, resp.Protocol)
|
|
return nil
|
|
}
|
|
|
|
if probeRetryable(err) && time.Now().Before(deadline) {
|
|
time.Sleep(standaloneProbeInterval)
|
|
continue
|
|
}
|
|
|
|
var mismatch *protocolMismatchError
|
|
if errors.As(err, &mismatch) {
|
|
return fmt.Errorf("%w (still incompatible after %v, so this is a version skew rather than a rollout in progress)",
|
|
err, standaloneProbeWindow)
|
|
}
|
|
|
|
log.Printf("WARNING: iam standalone: could not verify the IAM service at %q: %v; "+
|
|
"the private protocol version is still checked on every request",
|
|
s.cfg.Endpoint, err)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// probeRetryable reports whether a failed probe could resolve on its own.
|
|
// Only two can: the service not being up yet, and it being mid-rollout at an
|
|
// incompatible version. Anything it answered definitively — a rejected
|
|
// gateway credential above all — will answer the same way in thirty seconds,
|
|
// so retrying only delays the warning that says so.
|
|
func probeRetryable(err error) bool {
|
|
var mismatch *protocolMismatchError
|
|
if errors.As(err, &mismatch) {
|
|
return true
|
|
}
|
|
var transport *url.Error
|
|
return errors.As(err, &transport)
|
|
}
|
|
|
|
func newStandaloneHTTPClient(cfg IAMServiceStandaloneConfig) (*http.Client, string, error) {
|
|
if netutil.IsUnixSocketPath(cfg.Endpoint) {
|
|
sock := cfg.Endpoint
|
|
transport := &http.Transport{
|
|
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
|
|
return (&net.Dialer{}).DialContext(ctx, "unix", sock)
|
|
},
|
|
}
|
|
// The host in this URL is never actually resolved/dialed — the
|
|
// DialContext override above always connects to the unix socket
|
|
// regardless — it just needs to be a syntactically valid URL.
|
|
return &http.Client{Transport: transport, Timeout: standaloneRequestTimeout}, "http://unix", nil
|
|
}
|
|
|
|
if cfg.ClientCert == "" || cfg.ClientCertKey == "" || cfg.ServerCA == "" {
|
|
return nil, "", fmt.Errorf("iam standalone: client-cert, client-cert-key, and server-ca are all required for a TCP endpoint (%q)", cfg.Endpoint)
|
|
}
|
|
|
|
cert, err := netutil.LoadClientCert(cfg.ClientCert, cfg.ClientCertKey)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("iam standalone: %w", err)
|
|
}
|
|
pool, err := netutil.LoadCACertPool(cfg.ServerCA)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("iam standalone: %w", err)
|
|
}
|
|
|
|
transport := &http.Transport{
|
|
TLSClientConfig: &tls.Config{
|
|
MinVersion: tls.VersionTLS12,
|
|
Certificates: []tls.Certificate{cert},
|
|
RootCAs: pool,
|
|
},
|
|
}
|
|
return &http.Client{Transport: transport, Timeout: standaloneRequestTimeout}, "https://" + cfg.Endpoint, nil
|
|
}
|
|
|
|
// doPrivateRequest signs reqBody as this client's own identity (s.access/
|
|
// s.secret, the one place in this file that touches a secret directly —
|
|
// signing an outbound request as itself, not verifying an inbound one) and
|
|
// POSTs it to path, unmarshaling the response into respBody.
|
|
//
|
|
// A 403 is dispatched on the error body's code: an unresolvable access key
|
|
// becomes ErrNoSuchUser (matching IAMService.GetUserAccount's contract), a
|
|
// rejected security token becomes ErrInvalidSessionToken, and anything
|
|
// else — most importantly this gateway's own IAM-client credential being
|
|
// rejected — stays a plain error, so a gateway misconfiguration surfaces as
|
|
// a server fault instead of telling the end user their access key doesn't
|
|
// exist.
|
|
func (s *IAMServiceStandalone) doPrivateRequest(path string, reqBody, respBody any) error {
|
|
bodyBytes, err := json.Marshal(reqBody)
|
|
if err != nil {
|
|
return fmt.Errorf("iam standalone: marshal request: %w", err)
|
|
}
|
|
|
|
req, err := http.NewRequest(http.MethodPost, s.baseURL+path, bytes.NewReader(bodyBytes))
|
|
if err != nil {
|
|
return fmt.Errorf("iam standalone: build request: %w", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
// Set before signing, so it is covered by the signature: SigningInput
|
|
// FromRequest leaves SignedHeaders nil, and sigv4auth's default policy
|
|
// excludes only Authorization, User-Agent, X-Amzn-Trace-Id, Expect and
|
|
// Transfer-Encoding.
|
|
req.Header.Set(private.ProtocolHeader, strconv.Itoa(private.ProtocolVersion))
|
|
|
|
payloadHash := sigv4auth.PayloadSHA256Hex(bodyBytes)
|
|
req.Header.Set("X-Amz-Content-Sha256", payloadHash)
|
|
|
|
signingTime := time.Now().UTC()
|
|
yyyymmdd := signingTime.Format(sigv4auth.YYYYMMDD)
|
|
derivedKey := sigv4auth.DeriveKey(s.secret, yyyymmdd, standaloneSigningRegion, standaloneSigningService)
|
|
in := sigv4auth.SigningInputFromRequest(req)
|
|
in.AccessKeyID = s.access
|
|
in.CredentialScope = sigv4auth.BuildCredentialScope(yyyymmdd, standaloneSigningRegion, standaloneSigningService)
|
|
in.PayloadHash = payloadHash
|
|
in.SigningTime = signingTime
|
|
in.DisableURIPathEscaping = true
|
|
result := sigv4auth.BuildAndSign(derivedKey, in)
|
|
req.Header.Set("X-Amz-Date", result.AmzDate)
|
|
req.Header.Set("Authorization", result.AuthorizationHeader)
|
|
|
|
resp, err := s.client.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("iam standalone: request to %s failed: %w", path, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
// Checked before the status and before the body: a peer whose protocol
|
|
// this gateway cannot read is not one whose response it should interpret.
|
|
if err := s.checkServerProtocol(path, resp); err != nil {
|
|
return err
|
|
}
|
|
|
|
respBytes, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return fmt.Errorf("iam standalone: read response from %s: %w", path, err)
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return standaloneResponseError(path, resp.StatusCode, respBytes)
|
|
}
|
|
|
|
if respBody != nil {
|
|
if err := json.Unmarshal(respBytes, respBody); err != nil {
|
|
return fmt.Errorf("iam standalone: unmarshal response from %s: %w", path, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// standaloneResponseError turns a non-200 private-endpoint response into
|
|
// the sentinel the S3 request pipeline dispatches on, using the JSON error
|
|
// body's machine-readable code rather than the status alone (403 covers
|
|
// several distinct failures, only two of which are about the *end user's*
|
|
// credential).
|
|
func standaloneResponseError(path string, status int, body []byte) error {
|
|
var errBody struct {
|
|
Error string `json:"error"`
|
|
Code string `json:"code"`
|
|
}
|
|
// A body that doesn't parse leaves Code empty, which falls through to
|
|
// the generic error below — the safe direction, since misreporting a
|
|
// server fault as "no such user" is what this dispatch exists to avoid.
|
|
_ = json.Unmarshal(body, &errBody)
|
|
|
|
switch errBody.Code {
|
|
case private.CodeNoSuchIdentity:
|
|
return ErrNoSuchUser
|
|
case private.CodeInvalidToken:
|
|
return ErrInvalidSessionToken
|
|
case private.CodeProtocolMismatch:
|
|
// The other direction of the same check: this gateway is too old for
|
|
// the IAM service to serve safely, which only that service can know.
|
|
return &protocolMismatchError{fmt.Sprintf(
|
|
"IAM service refused this gateway's private protocol version %d at %s: %s",
|
|
private.ProtocolVersion, path, errBody.Error)}
|
|
}
|
|
|
|
return fmt.Errorf("iam standalone: %s returned %d: %s", path, status, string(body))
|
|
}
|
|
|
|
// checkServerProtocol verifies the private protocol version the IAM service
|
|
// declared on a response.
|
|
//
|
|
// A missing version fails just as hard as an incompatible one. No build of
|
|
// this protocol omits the header, so a response without one did not come from
|
|
// a compatible IAM service — it came from something else answering on that
|
|
// address, such as a proxy returning its own error page. The message says
|
|
// what was observed rather than naming a cause, since both look identical from here.
|
|
func (s *IAMServiceStandalone) checkServerProtocol(path string, resp *http.Response) error {
|
|
value := resp.Header.Get(private.ProtocolHeader)
|
|
if value == "" {
|
|
return &protocolMismatchError{fmt.Sprintf(
|
|
"no %s header on the %d response from %s at %q: not a versioned IAM service",
|
|
private.ProtocolHeader, resp.StatusCode, path, s.cfg.Endpoint)}
|
|
}
|
|
|
|
server, err := private.ParseProtocolVersion(value)
|
|
if err != nil {
|
|
return &protocolMismatchError{fmt.Sprintf("response from %s at %q: %v", path, s.cfg.Endpoint, err)}
|
|
}
|
|
|
|
if server < private.ProtocolVersion {
|
|
return &protocolMismatchError{fmt.Sprintf(
|
|
"IAM service at %q speaks private protocol %d, this gateway requires %d or newer: upgrade the IAM service before the gateway",
|
|
s.cfg.Endpoint, server, private.ProtocolVersion)}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// DeriveSigningKey implements SigningKeyProvider. Root is special-cased
|
|
// locally: its secret is already known to this process either way, so
|
|
// there's no reason to round-trip it through the IAM service.
|
|
func (s *IAMServiceStandalone) DeriveSigningKey(access, sessionToken, date, region, service string) ([]byte, Account, error) {
|
|
if access == s.rootAcc.Access {
|
|
if sessionToken != "" {
|
|
return nil, Account{}, ErrInvalidSessionToken
|
|
}
|
|
return sigv4auth.DeriveKey(s.rootAcc.Secret, date, region, service), s.rootAcc, nil
|
|
}
|
|
|
|
var resp private.DeriveSigningKeyResponse
|
|
err := s.doPrivateRequest(private.DerivePath, private.DeriveSigningKeyRequest{
|
|
AccessKeyID: access,
|
|
SessionToken: sessionToken,
|
|
Date: date,
|
|
Region: region,
|
|
Service: service,
|
|
}, &resp)
|
|
if err != nil {
|
|
return nil, Account{}, err
|
|
}
|
|
|
|
return resp.DerivedKey, s.accountFor(access, sessionToken), nil
|
|
}
|
|
|
|
// accountFor builds the Account metadata DeriveSigningKey/GetUserAccount
|
|
// return for a resolved non-root identity
|
|
func (s *IAMServiceStandalone) accountFor(access, sessionToken string) Account {
|
|
return Account{
|
|
Access: access,
|
|
Role: RoleUser,
|
|
UserID: s.cfg.DefaultUserID,
|
|
GroupID: s.cfg.DefaultGroupID,
|
|
ProjectID: s.cfg.DefaultProjectID,
|
|
SessionToken: sessionToken,
|
|
IsSession: sigv4auth.IsTempAccessKeyID(access),
|
|
}
|
|
}
|
|
|
|
// EvaluatePolicy implements PolicyEvaluator, evaluating every action in
|
|
// actions against resource in a single request rather than one round trip
|
|
// per action.
|
|
func (s *IAMServiceStandalone) EvaluatePolicy(access, sessionToken string, actions []Action, resources []string, condition map[string][]string) (PolicyEvaluation, error) {
|
|
actionStrs := make([]string, len(actions))
|
|
for i, action := range actions {
|
|
actionStrs[i] = string(action)
|
|
}
|
|
|
|
var resp private.EvaluatePolicyResponse
|
|
err := s.doPrivateRequest(private.EvaluatePath, private.EvaluatePolicyRequest{
|
|
AccessKeyID: access,
|
|
SessionToken: sessionToken,
|
|
Actions: actionStrs,
|
|
Resources: resources,
|
|
Condition: condition,
|
|
}, &resp)
|
|
if err != nil {
|
|
return PolicyEvaluation{}, err
|
|
}
|
|
if len(resp.Decisions) != len(resources) {
|
|
// A protocol mismatch between the gateway and IAM service builds —
|
|
// fail closed rather than silently under- or over-evaluating the
|
|
// requested matrix.
|
|
return PolicyEvaluation{}, fmt.Errorf("iam standalone: evaluate-policy returned %d resource decisions for %d resources", len(resp.Decisions), len(resources))
|
|
}
|
|
|
|
decisions, err := decisionMatrixFromWire(resp.Decisions, len(actions))
|
|
if err != nil {
|
|
return PolicyEvaluation{}, err
|
|
}
|
|
|
|
eval := PolicyEvaluation{
|
|
Decisions: decisions,
|
|
PrincipalArn: resp.PrincipalArn,
|
|
}
|
|
|
|
if resp.HasSessionPolicy {
|
|
if len(resp.SessionDecisions) != len(resources) {
|
|
return PolicyEvaluation{}, fmt.Errorf("iam standalone: evaluate-policy returned %d session-decision rows for %d resources", len(resp.SessionDecisions), len(resources))
|
|
}
|
|
sessionDecisions, err := decisionMatrixFromWire(resp.SessionDecisions, len(actions))
|
|
if err != nil {
|
|
return PolicyEvaluation{}, err
|
|
}
|
|
eval.HasSessionPolicy = true
|
|
eval.SessionDecisions = sessionDecisions
|
|
}
|
|
|
|
return eval, nil
|
|
}
|
|
|
|
// decisionMatrixFromWire converts one wire decision matrix, checking every
|
|
// row is the expected width. A short row is a protocol mismatch between
|
|
// gateway and IAM service builds, and is failed closed rather than padded.
|
|
func decisionMatrixFromWire(rows [][]string, actionCount int) ([][]policyDecision, error) {
|
|
out := make([][]policyDecision, len(rows))
|
|
for i, perAction := range rows {
|
|
if len(perAction) != actionCount {
|
|
return nil, fmt.Errorf("iam standalone: evaluate-policy returned %d action decisions for %d actions", len(perAction), actionCount)
|
|
}
|
|
out[i] = make([]policyDecision, len(perAction))
|
|
for j, d := range perAction {
|
|
out[i][j] = decisionFromWireValue(d)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// decisionFromWireValue translates the private endpoint's wire-format
|
|
// Decision string to the auth package's own policyDecision. An unrecognized
|
|
// value (a protocol mismatch between mismatched gateway/IAM-service builds)
|
|
// fails closed as Deny rather than silently granting access.
|
|
func decisionFromWireValue(v string) policyDecision {
|
|
switch v {
|
|
case private.DecisionAllow:
|
|
return policyDecisionAllow
|
|
case private.DecisionNoMatch:
|
|
return policyDecisionNoMatch
|
|
default:
|
|
return policyDecisionDeny
|
|
}
|
|
}
|
|
|
|
// GetUserAccount resolves access via the resolve-identity endpoint, which
|
|
// answers existence and principal identity while returning no credential
|
|
// material at all. This is not blanket-unsupported like the mutating
|
|
// methods below: ResolveAccounts (bucket-policy Principal and ACL grantee
|
|
// validation) depends on GetUserAccount working to tell a nonexistent
|
|
// grantee (ErrNoSuchUser) apart from an unsupported one
|
|
// (ErrAdminMethodNotSupported, which it treats as fatal).
|
|
//
|
|
// Callers that need to validate several access keys at once should use
|
|
// ResolveAccounts instead — one round trip for the whole set rather than
|
|
// one per key.
|
|
func (s *IAMServiceStandalone) GetUserAccount(access string) (Account, error) {
|
|
if access == s.rootAcc.Access {
|
|
return s.rootAcc, nil
|
|
}
|
|
|
|
accounts, err := s.resolveAccountDetails([]string{access})
|
|
if err != nil {
|
|
return Account{}, err
|
|
}
|
|
if !accounts[0].Found {
|
|
return Account{}, ErrNoSuchUser
|
|
}
|
|
return accounts[0].Account, nil
|
|
}
|
|
|
|
// resolvedAccount is one resolveAccountDetails result. The zero value means
|
|
// "no such access key".
|
|
type resolvedAccount struct {
|
|
Found bool
|
|
// IsSession distinguishes an ephemeral AssumeRoleWithWebIdentity
|
|
// session from a long-term user. Callers persisting a reference to a
|
|
// principal (a bucket policy Principal, an ACL grantee, a bucket owner)
|
|
// must refuse a session: the ASIA… key it is named by stops existing
|
|
// when the session expires, leaving a reference that can never match
|
|
// and, for a bucket owner, a bucket nobody but root can administer.
|
|
IsSession bool
|
|
Account Account
|
|
}
|
|
|
|
// resolveAccountDetails resolves every access key in accesses in a single
|
|
// round trip, returning one positional result per input. Only the root
|
|
// account is answered locally; a root access key mixed into the batch still
|
|
// costs nothing, since it never reaches the IAM service.
|
|
func (s *IAMServiceStandalone) resolveAccountDetails(accesses []string) ([]resolvedAccount, error) {
|
|
out := make([]resolvedAccount, len(accesses))
|
|
|
|
// Root is known to this process, so it is answered here and left out of
|
|
// the request entirely — the IAM service has no record of it.
|
|
remote := make([]string, 0, len(accesses))
|
|
remoteIdx := make([]int, 0, len(accesses))
|
|
for i, access := range accesses {
|
|
if access == s.rootAcc.Access {
|
|
out[i] = resolvedAccount{Found: true, Account: s.rootAcc}
|
|
continue
|
|
}
|
|
remote = append(remote, access)
|
|
remoteIdx = append(remoteIdx, i)
|
|
}
|
|
if len(remote) == 0 {
|
|
return out, nil
|
|
}
|
|
|
|
var resp private.ResolveIdentityResponse
|
|
err := s.doPrivateRequest(private.ResolveIdentityPath, private.ResolveIdentityRequest{
|
|
AccessKeyIDs: remote,
|
|
}, &resp)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(resp.Identities) != len(remote) {
|
|
// A protocol mismatch between the gateway and IAM service builds —
|
|
// fail closed rather than silently mis-attributing results to the
|
|
// wrong access keys.
|
|
return nil, fmt.Errorf("iam standalone: resolve-identity returned %d identities for %d access keys", len(resp.Identities), len(remote))
|
|
}
|
|
|
|
for i, identity := range resp.Identities {
|
|
if !identity.Found {
|
|
continue
|
|
}
|
|
out[remoteIdx[i]] = resolvedAccount{
|
|
Found: true,
|
|
IsSession: identity.Kind == private.KindSession,
|
|
// No session token is known here, and none is needed: this
|
|
// Account answers "who is this" for validation, never
|
|
// authenticates a request.
|
|
Account: s.accountFor(remote[i], ""),
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// ResolveAccounts returns the subset of accessKeyIDs that do not exist, in
|
|
// a single round trip. A temporary (ASIA…) session access key counts as
|
|
// nonexistent even while its session is live — see resolvedAccount.IsSession.
|
|
func (s *IAMServiceStandalone) ResolveAccounts(accessKeyIDs []string) ([]string, error) {
|
|
resolved, err := s.resolveAccountDetails(accessKeyIDs)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("check user account: %w", err)
|
|
}
|
|
missing := []string{}
|
|
for i, acc := range resolved {
|
|
if !acc.Found || acc.IsSession {
|
|
missing = append(missing, accessKeyIDs[i])
|
|
}
|
|
}
|
|
return missing, nil
|
|
}
|
|
|
|
// CreateAccount is not supported
|
|
func (s *IAMServiceStandalone) CreateAccount(Account) error {
|
|
return s3err.GetAPIError(s3err.ErrAdminMethodNotSupported)
|
|
}
|
|
|
|
// UpdateUserAccount is not supported
|
|
func (s *IAMServiceStandalone) UpdateUserAccount(string, MutableProps) error {
|
|
return s3err.GetAPIError(s3err.ErrAdminMethodNotSupported)
|
|
}
|
|
|
|
// DeleteUserAccount is not supported
|
|
func (s *IAMServiceStandalone) DeleteUserAccount(string) error {
|
|
return s3err.GetAPIError(s3err.ErrAdminMethodNotSupported)
|
|
}
|
|
|
|
// ListUserAccounts is not supported
|
|
func (s *IAMServiceStandalone) ListUserAccounts() ([]Account, error) {
|
|
return nil, s3err.GetAPIError(s3err.ErrAdminMethodNotSupported)
|
|
}
|
|
|
|
func (s *IAMServiceStandalone) Shutdown() error {
|
|
s.client.CloseIdleConnections()
|
|
return nil
|
|
}
|