mirror of
https://github.com/versity/versitygw.git
synced 2026-09-23 08:24:17 +00:00
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.
268 lines
9.7 KiB
Go
268 lines
9.7 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
|
|
|
|
import (
|
|
"encoding/json"
|
|
"maps"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gofiber/fiber/v3"
|
|
"github.com/versity/versitygw/debuglogger"
|
|
"github.com/versity/versitygw/iamapi/internal/iammiddleware"
|
|
"github.com/versity/versitygw/iamapi/internal/iamutil"
|
|
"github.com/versity/versitygw/iamapi/policy"
|
|
"github.com/versity/versitygw/iamapi/types"
|
|
"github.com/versity/versitygw/internal/sigv4auth"
|
|
)
|
|
|
|
// handleVersion reports what this build speaks. It is root-signed like every
|
|
// other endpoint here, which is what lets the gateway's startup probe verify
|
|
// its own credential and its mTLS transport in the same round trip that
|
|
// verifies the protocol — a rotated gateway credential is a far more common
|
|
// misconfiguration than a version skew, and an unauthenticated probe would
|
|
// report success right through one.
|
|
func (p *PrivateAPI) handleVersion(ctx fiber.Ctx) error {
|
|
return ctx.JSON(VersionResponse{
|
|
Protocol: ProtocolVersion,
|
|
MinClient: MinClientProtocol,
|
|
ServerVersion: p.serverVersion,
|
|
AccountID: iamutil.DefaultAccountID,
|
|
})
|
|
}
|
|
|
|
func (p *PrivateAPI) handleDeriveSigningKey(ctx fiber.Ctx) error {
|
|
var req DeriveSigningKeyRequest
|
|
if err := json.Unmarshal(ctx.Body(), &req); err != nil {
|
|
return errMalformedRequestBody
|
|
}
|
|
|
|
identity, secret, err := resolvePrivateIdentity(ctx.Context(), p.store, req.AccessKeyID, req.SessionToken)
|
|
if err != nil {
|
|
return mapResolveError(err)
|
|
}
|
|
|
|
derivedKey := sigv4auth.DeriveKey(secret, req.Date, req.Region, req.Service)
|
|
|
|
resp := DeriveSigningKeyResponse{
|
|
DerivedKey: derivedKey,
|
|
PrincipalArn: iammiddleware.CallerArn(*identity),
|
|
}
|
|
// The role ARN comes from the session rather than from the role the
|
|
// store holds now: a session outliving its role keeps authenticating,
|
|
// and it still belongs to the role it was minted against.
|
|
if identity.Session != nil {
|
|
resp.RoleArn = identity.Session.RoleArn
|
|
}
|
|
|
|
return ctx.JSON(resp)
|
|
}
|
|
|
|
// recordDataPlaneUsage records this S3 request as a use of the credential
|
|
// that made it — an access key's GetAccessKeyLastUsed metadata, a session's
|
|
// role's RoleLastUsed, or both for a session (its role is what AWS reports,
|
|
// and a session has no long-term key of its own). It is the data-plane
|
|
// counterpart of what iammiddleware.VerifyIAMAuth records for the IAM/STS
|
|
// control plane, and is deliberately here rather than on derive-signing-key:
|
|
// this endpoint is only reached once the gateway has verified the request's
|
|
// signature, so an unauthenticated caller who merely knows an access key id
|
|
// cannot refresh — or, since it would supply the credential scope, poison —
|
|
// another identity's last-used record.
|
|
//
|
|
// Everything about it is best-effort: failures are logged and dropped, and a
|
|
// gateway too old to send Region/Service records nothing at all rather than
|
|
// storing a blank service or region.
|
|
func (p *PrivateAPI) recordDataPlaneUsage(ctx fiber.Ctx, identity types.Identity, req EvaluatePolicyRequest) {
|
|
if req.Region == "" || req.Service == "" {
|
|
return
|
|
}
|
|
|
|
now := time.Now().UTC()
|
|
if identity.User != nil {
|
|
if err := p.store.RecordAccessKeyUsage(ctx.Context(), req.AccessKeyID, req.Service, req.Region, now); err != nil {
|
|
debuglogger.Logf("failed to record access key last-used metadata for %q: %v", req.AccessKeyID, err)
|
|
}
|
|
}
|
|
// identity.Role is set only when the session's role still exists and is
|
|
// still the one the session was minted against, so a session outliving
|
|
// its role records nothing rather than attributing its use to a
|
|
// same-named replacement — same rule as the control plane.
|
|
if identity.Role != nil {
|
|
if err := p.store.RecordRoleUsage(ctx.Context(), identity.Role.RoleName, req.Region, now); err != nil {
|
|
debuglogger.Logf("failed to record role last-used metadata for %q: %v", identity.Role.RoleName, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// handleResolveIdentity answers "does this access key exist, and what
|
|
// principal is it" for a batch of access key ids, returning no credential
|
|
// material at all — see ResolveIdentityResponse for why that is what makes
|
|
// answering for a session, with no session token, safe.
|
|
func (p *PrivateAPI) handleResolveIdentity(ctx fiber.Ctx) error {
|
|
var req ResolveIdentityRequest
|
|
if err := json.Unmarshal(ctx.Body(), &req); err != nil {
|
|
return errMalformedRequestBody
|
|
}
|
|
|
|
resolved := resolveIdentityMetadata(ctx.Context(), p.store, req.AccessKeyIDs)
|
|
|
|
identities := make([]ResolvedIdentity, len(resolved))
|
|
for i, r := range resolved {
|
|
if !r.Found {
|
|
continue
|
|
}
|
|
identities[i] = ResolvedIdentity{
|
|
Found: true,
|
|
Kind: identityKindWireValue(r.Kind),
|
|
PrincipalArn: r.PrincipalArn,
|
|
}
|
|
}
|
|
|
|
return ctx.JSON(ResolveIdentityResponse{Identities: identities})
|
|
}
|
|
|
|
// handleResolvePrincipals answers, for each string a bucket policy names as
|
|
// a Principal, whether it resolves to something that exists — reporting only
|
|
// the ones that do not, so a valid policy's principals disclose nothing.
|
|
func (p *PrivateAPI) handleResolvePrincipals(ctx fiber.Ctx) error {
|
|
var req ResolvePrincipalsRequest
|
|
if err := json.Unmarshal(ctx.Body(), &req); err != nil {
|
|
return errMalformedRequestBody
|
|
}
|
|
|
|
invalid := []string{}
|
|
for _, principal := range req.Principals {
|
|
resolves, err := principalResolves(ctx.Context(), p.store, iamutil.DefaultAccountID, principal)
|
|
if err != nil {
|
|
// A store fault, not a verdict on the principal — errorHandler
|
|
// renders it as a 500 so the gateway reports the service as
|
|
// broken rather than the policy as malformed.
|
|
return err
|
|
}
|
|
if !resolves {
|
|
invalid = append(invalid, principal)
|
|
}
|
|
}
|
|
|
|
return ctx.JSON(ResolvePrincipalsResponse{Invalid: invalid})
|
|
}
|
|
|
|
// identityKindWireValue converts identityKind to its wire representation.
|
|
func identityKindWireValue(k identityKind) string {
|
|
if k == identityKindSession {
|
|
return KindSession
|
|
}
|
|
return KindUser
|
|
}
|
|
|
|
func (p *PrivateAPI) handleEvaluatePolicy(ctx fiber.Ctx) error {
|
|
var req EvaluatePolicyRequest
|
|
if err := json.Unmarshal(ctx.Body(), &req); err != nil {
|
|
return errMalformedRequestBody
|
|
}
|
|
|
|
identity, _, err := resolvePrivateIdentity(ctx.Context(), p.store, req.AccessKeyID, req.SessionToken)
|
|
if err != nil {
|
|
return mapResolveError(err)
|
|
}
|
|
|
|
p.recordDataPlaneUsage(ctx, *identity, req)
|
|
|
|
condition := conditionContextFor(*identity, req.Condition)
|
|
|
|
decisions := make([][]string, len(req.Resources))
|
|
sessionDecisions := make([][]string, len(req.Resources))
|
|
hasSessionPolicy := false
|
|
|
|
for i, resource := range req.Resources {
|
|
perAction := make([]string, len(req.Actions))
|
|
perActionSession := make([]string, len(req.Actions))
|
|
for j, action := range req.Actions {
|
|
identityDecision, sessionDecision, hasSession := iammiddleware.AuthorizeSplit(*identity, policy.RequestContext{
|
|
Action: action,
|
|
Resource: resource,
|
|
Condition: condition,
|
|
})
|
|
perAction[j] = decisionWireValue(identityDecision)
|
|
perActionSession[j] = decisionWireValue(sessionDecision)
|
|
hasSessionPolicy = hasSession
|
|
}
|
|
decisions[i] = perAction
|
|
sessionDecisions[i] = perActionSession
|
|
}
|
|
|
|
resp := EvaluatePolicyResponse{
|
|
Decisions: decisions,
|
|
PrincipalArn: iammiddleware.CallerArn(*identity),
|
|
}
|
|
if hasSessionPolicy {
|
|
resp.HasSessionPolicy = true
|
|
resp.SessionDecisions = sessionDecisions
|
|
}
|
|
|
|
return ctx.JSON(resp)
|
|
}
|
|
|
|
// conditionContextFor combines the request-derived condition keys the S3
|
|
// gateway observed (source IP, time, transport) with the identity-derived
|
|
// keys only this service can know (aws:PrincipalArn, aws:username, …).
|
|
//
|
|
// Every key in an identity or resource namespace is dropped from the
|
|
// gateway's contribution first, then this side's own values are laid over
|
|
// the remainder. Filtering rather than merging matters: an
|
|
// override-on-collision merge would leave any key this service happens
|
|
// *not* to set — aws:PrincipalTag/x for an untagged role, say — under the
|
|
// gateway's control, which is precisely what a StringNotEquals-guarded
|
|
// Allow keys off. The gateway authenticates as root, so this is defense in
|
|
// depth rather than a trust boundary, but the layering costs nothing.
|
|
func conditionContextFor(identity types.Identity, requestKeys map[string][]string) map[string][]string {
|
|
condition := make(map[string][]string, len(requestKeys))
|
|
for k, v := range requestKeys {
|
|
if isIdentityConditionKey(k) {
|
|
continue
|
|
}
|
|
condition[k] = v
|
|
}
|
|
maps.Copy(condition, iammiddleware.IdentityConditionContext(identity))
|
|
return condition
|
|
}
|
|
|
|
// isIdentityConditionKey reports whether key names the caller or the
|
|
// resource, and so may only be set by this service. Matching is
|
|
// case-insensitive because policy condition-key lookup is
|
|
// (iamapi/policy.lookupContextValues) — a caller must not be able to smuggle
|
|
// "AWS:PrincipalArn" past a case-sensitive filter.
|
|
func isIdentityConditionKey(key string) bool {
|
|
for _, prefix := range iammiddleware.IdentityConditionKeyPrefixes {
|
|
if strings.EqualFold(key, prefix) ||
|
|
(strings.HasSuffix(prefix, "/") && len(key) > len(prefix) && strings.EqualFold(key[:len(prefix)], prefix)) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// decisionWireValue converts policy.Decision to its wire representation.
|
|
func decisionWireValue(d policy.Decision) string {
|
|
switch d {
|
|
case policy.DecisionAllow:
|
|
return DecisionAllow
|
|
case policy.DecisionDeny:
|
|
return DecisionDeny
|
|
default:
|
|
return DecisionNoMatch
|
|
}
|
|
}
|