mirror of
https://github.com/versity/versitygw.git
synced 2026-09-22 07:54:14 +00:00
Role last-used tracking was missing entirely - `GetRole` returned a `RoleLastUsed` element that nothing ever wrote, rendering the zero time instead of the empty element AWS returns for an unused role - and access key last-used only ever saw the `IAM`/`STS` control plane, so a credential used exclusively against the S3 gateway reported as never used. Roles now record a use whenever a request authenticates with one of their session credentials, through a new `Storer.RecordRoleUsage` mirroring `RecordAccessKeyUsage`, gated on the session's role still being the one it was minted against so a session outliving its role can't attribute its use to a same-named replacement. `LastUsedDate` became a `*time.Time` so an unused role renders as an empty element. Both records now cover the S3 data plane as well: the gateway sends its configured region and `s3` on evaluate-policy and the IAM service records the caller there, so `GetAccessKeyLastUsed's` `ServiceName` is now iam, sts or s3. That call was chosen over derive-signing-key, which runs before signature verification and takes its region and service from the caller's own `Authorization` header - recording there would let anyone who knows an access key id refresh and poison another identity's audit record. Requests denied by a bucket policy or made against a public bucket are not recorded, since neither reaches identity-policy evaluation. To keep per-request recording affordable, an update is skipped while the stored record has the same service and region and is under a minute old; a change of either is written through immediately. Assuming a role is not a use, a request denied by an identity policy is, and both successful and denied S3 requests update the record. Also moves the `OIDC-dependent` tests into the `s3-iam-session` group so runoidctests.sh runs a single group.
229 lines
8.4 KiB
Go
229 lines
8.4 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/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,
|
|
})
|
|
}
|
|
|
|
func (p *PrivateAPI) handleDeriveSigningKey(ctx fiber.Ctx) error {
|
|
var req DeriveSigningKeyRequest
|
|
if err := json.Unmarshal(ctx.Body(), &req); err != nil {
|
|
return errMalformedRequestBody
|
|
}
|
|
|
|
_, 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)
|
|
|
|
return ctx.JSON(DeriveSigningKeyResponse{DerivedKey: derivedKey})
|
|
}
|
|
|
|
// 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})
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|