Files
versitygw/iamapi/private/server.go
T
niksis02 c84c5f645a feat: accept principal ARNs in bucket policies under standalone IAM
Bucket policy `Principal` named callers by access key id. Under the standalone IAM service it now names them by AWS-style ARN, as real S3 does: a user ARN, a role ARN (covering every session of that role), an assumed-role ARN (covering one session), the account root ARN or bare account id, or `*`. Every other IAM backend has no ARNs to name anything by and keeps access-key principals unchanged, gated on a new `auth.PrincipalResolver` capability interface that only the standalone client implements.

`auth.Account` carries `Arn` and `RoleArn`, filled at authentication time, so a session can be matched against both its own ARN and its role's. Principals are validated at PutBucketPolicy time through a new `/private/resolve-principals` endpoint, which rejects anything that does not name a live identity with `MalformedPolicy: Invalid principal in policy`.

An `Allow` naming the account root ARN or bare account id delegates to the account's own IAM rather than granting on its own, while a `Deny` naming it denies every principal in the account outright. Denial messages now name the caller by ARN wherever one exists.

Also fixes `aws:PrincipalArn` for assumed-role sessions, which reported the session ARN where AWS reports the role's, and stops an unreachable IAM service being reported as a malformed policy.
2026-09-03 23:51:55 +04:00

238 lines
9.3 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 private implements the standalone IAM service's private endpoint
// set: derive a SigV4 signing key, evaluate IAM identity policies, and
// resolve an access key id to the principal that owns it. All three exist
// purely so the S3 gateway can authenticate and authorize its own callers
// without ever holding a plaintext secret or a policy document itself.
// This is a separate fiber app from the public control-plane
// iamapi.IAMApiServer, meant to be served on its own listener(s), never the
// public one.
//
// The endpoints authenticate strictly as the configured root credential
// — only the S3 gateway itself, signing as its own IAM-client identity, ever
// legitimately calls them. Transport security is enforced by ServeMultiPort,
// not by request handling
package private
import (
"fmt"
"os"
"strconv"
"github.com/gofiber/fiber/v3"
"github.com/gofiber/fiber/v3/middleware/logger"
"github.com/versity/versitygw/iamapi/internal/iammiddleware"
"github.com/versity/versitygw/iamapi/storage"
"github.com/versity/versitygw/internal/sigv4auth"
)
const (
// These are exported so auth.IAMServiceStandalone (the S3-side client)
// shares one source of truth for the routes rather than duplicating the
// literal path strings.
DerivePath = "/private/derive-signing-key"
EvaluatePath = "/private/evaluate-policy"
ResolveIdentityPath = "/private/resolve-identity"
ResolvePrincipalsPath = "/private/resolve-principals"
VersionPath = "/private/version"
// ProtocolHeader carries the private protocol version each peer speaks.
// Both send it: the S3 gateway on every request, this service on every
// response, including error responses.
ProtocolHeader = "X-Vgw-Private-Protocol"
// ProtocolVersion is the private protocol version this build speaks, and
// MinClientProtocol the oldest S3 gateway it will serve. Together they
// express compatibility in both directions, since a skew can be unsafe
// from either side:
//
// - Bump ProtocolVersion when the gateway starts relying on something
// an older service would silently ignore — a new request field, or a
// new endpoint. An unrecognized field is dropped by json.Unmarshal,
// so an older service evaluating without one (Condition being the
// worked example) is fail-open. The gateway catches this itself by
// refusing a service older than the version it speaks.
//
// - Bump both when this service starts returning something an older
// gateway must understand to stay fail-closed — a new deny dimension,
// or a decision matrix that narrows an Allow, as HasSessionPolicy/
// SessionDecisions would have been had they landed later. An older
// gateway cannot detect this on its own: it does not know the field
// exists. This service refuses it instead.
//
// - Bump neither for an addition an older gateway can safely ignore.
//
// Changing an existing field's meaning in place is not a bump; it is a
// new route. The operational rule that falls out of all this: upgrade
// the IAM service before the gateways.
ProtocolVersion = 1
MinClientProtocol = 1
// privateService is the SigV4 credential-scope service name the S3
// gateway signs its own requests to these endpoints with. It's an
// internal detail — these routes aren't part of any AWS-compatible
// API — reusing "iam" is simplest and avoids inventing a new constant
// consumers on both sides would have to agree on.
privateService = sigv4auth.ServiceIAM
)
// PrivateAPI is the standalone IAM service's private endpoint set
type PrivateAPI struct {
app *fiber.App
store storage.Storer
socketPerm os.FileMode
quiet bool
serverVersion string
}
type PrivateAPIOption func(*PrivateAPI)
// WithPrivateSocketPerm sets the file-mode permission applied to any
// file-backed unix-socket listener address (no effect on TCP addresses or
// Linux abstract-namespace sockets).
func WithPrivateSocketPerm(perm os.FileMode) PrivateAPIOption {
return func(p *PrivateAPI) { p.socketPerm = perm }
}
// WithPrivateQuiet suppresses per-request summary logging, mirroring
// iamapi.WithQuiet for the public API. Callers should gate both on the same
// flag so the two log streams turn on and off together.
func WithPrivateQuiet() PrivateAPIOption {
return func(p *PrivateAPI) { p.quiet = true }
}
// WithPrivateServerVersion sets the build version the version endpoint
// reports. It is what lets an operator map a protocol number back to an
// image, so it is worth passing even though nothing decides compatibility
// on it.
func WithPrivateServerVersion(version string) PrivateAPIOption {
return func(p *PrivateAPI) { p.serverVersion = version }
}
// New constructs the private endpoint set. root is the identity these
// endpoints authenticate every request against.
func New(store storage.Storer, root iammiddleware.RootCredentials, opts ...PrivateAPIOption) (*PrivateAPI, error) {
if store == nil {
return nil, fmt.Errorf("iamapi/private: storer is required")
}
p := &PrivateAPI{store: store}
for _, opt := range opts {
opt(p)
}
app := fiber.New(fiber.Config{
AppName: "versitygw-iam-private",
ServerHeader: "VERSITYGW",
ErrorHandler: p.errorHandler,
})
p.app = app
if !p.quiet {
app.Use("*", logger.New(logger.Config{
Format: "${time} | vgw-iam-private | ${status} | ${latency} | ${ip} | ${method} | ${path} | ${error} | ${queryParams}\n",
}))
}
app.Use("*", p.checkProtocolVersion)
rootAuth := iammiddleware.VerifyRootOnlySigV4(privateService, &root)
app.Post(VersionPath, chainHandlers(rootAuth, p.handleVersion))
app.Post(DerivePath, chainHandlers(rootAuth, p.handleDeriveSigningKey))
app.Post(EvaluatePath, chainHandlers(rootAuth, p.handleEvaluatePolicy))
app.Post(ResolveIdentityPath, chainHandlers(rootAuth, p.handleResolveIdentity))
app.Post(ResolvePrincipalsPath, chainHandlers(rootAuth, p.handleResolvePrincipals))
return p, nil
}
// chainHandlers composes handlers into one, calling each in turn and
// stopping at the first error.
func chainHandlers(handlers ...fiber.Handler) fiber.Handler {
return func(ctx fiber.Ctx) error {
for _, h := range handlers {
if err := h(ctx); err != nil {
return err
}
}
return nil
}
}
// checkProtocolVersion answers every response with this build's protocol
// version and refuses a gateway too old for it to serve safely.
//
// It runs before root authentication so a version refusal is reported and
// logged as exactly that, rather than as a misleading 403 about the
// gateway's credential. The only thing that discloses to an unauthenticated
// peer is the protocol version, which the response header carries either
// way, on a listener that is already mTLS- or unix-socket-only.
func (p *PrivateAPI) checkProtocolVersion(ctx fiber.Ctx) error {
ctx.Set(ProtocolHeader, strconv.Itoa(ProtocolVersion))
// The version endpoint answers even a gateway this build refuses to
// serve: it is how that gateway finds out what it is talking to. Keyed
// on the path rather than on registration order, since a route
// registered ahead of this middleware would not get the header set
// above either.
if ctx.Path() == VersionPath {
return ctx.Next()
}
client, err := ParseProtocolVersion(ctx.Get(ProtocolHeader))
if err != nil {
return errProtocolMismatch(err.Error())
}
if client < MinClientProtocol {
return errProtocolMismatch(fmt.Sprintf(
"gateway speaks private protocol %d, this IAM service requires %d or newer: upgrade the gateway",
client, MinClientProtocol))
}
return ctx.Next()
}
// ParseProtocolVersion reads a ProtocolHeader value. It is shared by both
// peers so they agree on what the header means, and is deliberately strict:
// a value it cannot read is a mismatch, never a default. Treating an absent
// or unreadable version as some assumed one is the fail-open direction for
// the check everything else is gated on.
func ParseProtocolVersion(value string) (int, error) {
if value == "" {
return 0, fmt.Errorf("no %s header", ProtocolHeader)
}
// Bounded before it is echoed into an error: this value is attacker-
// controlled and ends up in a log line.
if len(value) > maxProtocolDigits {
return 0, fmt.Errorf("malformed %s header", ProtocolHeader)
}
// Digits only. Atoi by itself would also accept a leading sign, which is
// not a version.
for i := 0; i < len(value); i++ {
if value[i] < '0' || value[i] > '9' {
return 0, fmt.Errorf("malformed %s header %q", ProtocolHeader, value)
}
}
version, err := strconv.Atoi(value)
if err != nil || version < 1 {
return 0, fmt.Errorf("malformed %s header %q", ProtocolHeader, value)
}
return version, nil
}
// maxProtocolDigits bounds a protocol version's wire length, so an
// arbitrarily long header value never reaches a log line.
const maxProtocolDigits = 4