9d906d4632
The List RPC correctly authorized against the prefix argument. But when filtering results underneath the prefix, it only checked authorization for standard ACL tokens and not Workload Identity. This results in WI tokens being able to read List results (metadata only: variable paths and timestamps) for variables under the `nomad/` prefix that belong to other jobs in the same namespace. Fixes the filtering and split the `handleMixedAuthEndpoint` function into separate authentication and authorization steps so that we don't need to re-verify the claim token on each filtered object. Also includes: * update semgrep rule for mixed auth endpoints * variables: List returns empty set when all results are filtered
570 lines
16 KiB
Go
570 lines
16 KiB
Go
package nomad
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
metrics "github.com/armon/go-metrics"
|
|
"github.com/hashicorp/go-hclog"
|
|
memdb "github.com/hashicorp/go-memdb"
|
|
|
|
"github.com/hashicorp/nomad/acl"
|
|
"github.com/hashicorp/nomad/helper"
|
|
"github.com/hashicorp/nomad/nomad/state"
|
|
"github.com/hashicorp/nomad/nomad/state/paginator"
|
|
"github.com/hashicorp/nomad/nomad/structs"
|
|
)
|
|
|
|
// Variables encapsulates the variables RPC endpoint which is
|
|
// callable via the Variables RPCs and externally via the "/v1/var{s}"
|
|
// HTTP API.
|
|
type Variables struct {
|
|
srv *Server
|
|
logger hclog.Logger
|
|
encrypter *Encrypter
|
|
}
|
|
|
|
// Apply is used to apply a SV update request to the data store.
|
|
func (sv *Variables) Apply(args *structs.VariablesApplyRequest, reply *structs.VariablesApplyResponse) error {
|
|
if done, err := sv.srv.forward(structs.VariablesApplyRPCMethod, args, args, reply); done {
|
|
return err
|
|
}
|
|
defer metrics.MeasureSince([]string{
|
|
"nomad", "variables", "apply", string(args.Op)}, time.Now())
|
|
|
|
// Check if the Namespace is explicitly set on the variable. If
|
|
// not, use the RequestNamespace
|
|
if args.Var == nil {
|
|
return fmt.Errorf("variable must not be nil")
|
|
}
|
|
targetNS := args.Var.Namespace
|
|
if targetNS == "" {
|
|
targetNS = args.RequestNamespace()
|
|
args.Var.Namespace = targetNS
|
|
}
|
|
|
|
if !ServersMeetMinimumVersion(
|
|
sv.srv.serf.Members(), sv.srv.Region(), minVersionKeyring, true) {
|
|
return fmt.Errorf("all servers must be running version %v or later to apply variables", minVersionKeyring)
|
|
}
|
|
|
|
canRead, err := svePreApply(sv, args, args.Var)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var ev *structs.VariableEncrypted
|
|
|
|
switch args.Op {
|
|
case structs.VarOpSet, structs.VarOpCAS:
|
|
ev, err = sv.encrypt(args.Var)
|
|
if err != nil {
|
|
return fmt.Errorf("variable error: encrypt: %w", err)
|
|
}
|
|
now := time.Now().UnixNano()
|
|
ev.CreateTime = now // existing will override if it exists
|
|
ev.ModifyTime = now
|
|
case structs.VarOpDelete, structs.VarOpDeleteCAS:
|
|
ev = &structs.VariableEncrypted{
|
|
VariableMetadata: structs.VariableMetadata{
|
|
Namespace: args.Var.Namespace,
|
|
Path: args.Var.Path,
|
|
ModifyIndex: args.Var.ModifyIndex,
|
|
},
|
|
}
|
|
}
|
|
|
|
// Make a SVEArgs
|
|
sveArgs := structs.VarApplyStateRequest{
|
|
Op: args.Op,
|
|
Var: ev,
|
|
WriteRequest: args.WriteRequest,
|
|
}
|
|
|
|
// Apply the update.
|
|
out, index, err := sv.srv.raftApply(structs.VarApplyStateRequestType, sveArgs)
|
|
if err != nil {
|
|
return fmt.Errorf("raft apply failed: %w", err)
|
|
}
|
|
r, err := sv.makeVariablesApplyResponse(args, out.(*structs.VarApplyStateResponse), canRead)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
*reply = *r
|
|
reply.Index = index
|
|
return nil
|
|
}
|
|
|
|
func svePreApply(sv *Variables, args *structs.VariablesApplyRequest, vd *structs.VariableDecrypted) (canRead bool, err error) {
|
|
|
|
canRead = false
|
|
var aclObj *acl.ACL
|
|
|
|
// Perform the ACL token resolution.
|
|
if aclObj, err = sv.srv.ResolveToken(args.AuthToken); err != nil {
|
|
return
|
|
} else if aclObj != nil {
|
|
hasPerm := func(perm string) bool {
|
|
return aclObj.AllowVariableOperation(args.Var.Namespace,
|
|
args.Var.Path, perm)
|
|
}
|
|
canRead = hasPerm(acl.VariablesCapabilityRead)
|
|
|
|
switch args.Op {
|
|
case structs.VarOpSet, structs.VarOpCAS:
|
|
if !hasPerm(acl.VariablesCapabilityWrite) {
|
|
err = structs.ErrPermissionDenied
|
|
return
|
|
}
|
|
case structs.VarOpDelete, structs.VarOpDeleteCAS:
|
|
if !hasPerm(acl.VariablesCapabilityDestroy) {
|
|
err = structs.ErrPermissionDenied
|
|
return
|
|
}
|
|
default:
|
|
err = fmt.Errorf("svPreApply: unexpected VarOp received: %q", args.Op)
|
|
return
|
|
}
|
|
} else {
|
|
// ACLs are not enabled.
|
|
canRead = true
|
|
}
|
|
|
|
switch args.Op {
|
|
case structs.VarOpSet, structs.VarOpCAS:
|
|
args.Var.Canonicalize()
|
|
if err = args.Var.Validate(); err != nil {
|
|
return
|
|
}
|
|
|
|
case structs.VarOpDelete, structs.VarOpDeleteCAS:
|
|
if args.Var == nil || args.Var.Path == "" {
|
|
err = fmt.Errorf("delete requires a Path")
|
|
return
|
|
}
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
// MakeVariablesApplyResponse merges the output of this VarApplyStateResponse with the
|
|
// VariableDataItems
|
|
func (sv *Variables) makeVariablesApplyResponse(
|
|
req *structs.VariablesApplyRequest, eResp *structs.VarApplyStateResponse,
|
|
canRead bool) (*structs.VariablesApplyResponse, error) {
|
|
|
|
out := structs.VariablesApplyResponse{
|
|
Op: eResp.Op,
|
|
Input: req.Var,
|
|
Result: eResp.Result,
|
|
Error: eResp.Error,
|
|
WriteMeta: eResp.WriteMeta,
|
|
}
|
|
|
|
if eResp.IsOk() {
|
|
if eResp.WrittenSVMeta != nil {
|
|
// The writer is allowed to read their own write
|
|
out.Output = &structs.VariableDecrypted{
|
|
VariableMetadata: *eResp.WrittenSVMeta,
|
|
Items: req.Var.Items.Copy(),
|
|
}
|
|
}
|
|
return &out, nil
|
|
}
|
|
if eResp.IsError() {
|
|
return &out, eResp.Error
|
|
}
|
|
|
|
// At this point, the response is necessarily a conflict.
|
|
// Prime output from the encrypted responses metadata
|
|
out.Conflict = &structs.VariableDecrypted{
|
|
VariableMetadata: eResp.Conflict.VariableMetadata,
|
|
Items: nil,
|
|
}
|
|
|
|
// If the caller can't read the conflicting value, return the
|
|
// metadata, but no items and flag it as redacted
|
|
if !canRead {
|
|
out.Result = structs.VarOpResultRedacted
|
|
return &out, nil
|
|
}
|
|
|
|
if eResp.Conflict == nil || eResp.Conflict.KeyID == "" {
|
|
// zero-value conflicts can be returned for delete-if-set
|
|
dv := &structs.VariableDecrypted{}
|
|
dv.Namespace = eResp.Conflict.Namespace
|
|
dv.Path = eResp.Conflict.Path
|
|
out.Conflict = dv
|
|
} else {
|
|
// At this point, the caller has read access to the conflicting
|
|
// value so we can return it in the output; decrypt it.
|
|
dv, err := sv.decrypt(eResp.Conflict)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out.Conflict = dv
|
|
}
|
|
|
|
return &out, nil
|
|
}
|
|
|
|
// Read is used to get a specific variable
|
|
func (sv *Variables) Read(args *structs.VariablesReadRequest, reply *structs.VariablesReadResponse) error {
|
|
if done, err := sv.srv.forward(structs.VariablesReadRPCMethod, args, args, reply); done {
|
|
return err
|
|
}
|
|
defer metrics.MeasureSince([]string{"nomad", "variables", "read"}, time.Now())
|
|
|
|
_, _, err := sv.handleMixedAuthEndpoint(args.QueryOptions,
|
|
acl.PolicyRead, args.Path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Setup the blocking query
|
|
opts := blockingOptions{
|
|
queryOpts: &args.QueryOptions,
|
|
queryMeta: &reply.QueryMeta,
|
|
run: func(ws memdb.WatchSet, s *state.StateStore) error {
|
|
out, err := s.GetVariable(ws, args.RequestNamespace(), args.Path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Setup the output
|
|
reply.Data = nil
|
|
if out != nil {
|
|
dv, err := sv.decrypt(out)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
ov := dv.Copy()
|
|
reply.Data = &ov
|
|
reply.Index = out.ModifyIndex
|
|
} else {
|
|
sv.srv.setReplyQueryMeta(s, state.TableVariables, &reply.QueryMeta)
|
|
}
|
|
return nil
|
|
}}
|
|
return sv.srv.blockingRPC(&opts)
|
|
}
|
|
|
|
// List is used to list variables held within state. It supports single
|
|
// and wildcard namespace listings.
|
|
func (sv *Variables) List(
|
|
args *structs.VariablesListRequest,
|
|
reply *structs.VariablesListResponse) error {
|
|
|
|
if done, err := sv.srv.forward(structs.VariablesListRPCMethod, args, args, reply); done {
|
|
return err
|
|
}
|
|
defer metrics.MeasureSince([]string{"nomad", "variables", "list"}, time.Now())
|
|
|
|
// If the caller has requested to list variables across all namespaces, use
|
|
// the custom function to perform this.
|
|
if args.RequestNamespace() == structs.AllNamespacesSentinel {
|
|
return sv.listAllVariables(args, reply)
|
|
}
|
|
|
|
aclObj, claims, err := sv.authenticate(args.QueryOptions)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Set up and return the blocking query.
|
|
return sv.srv.blockingRPC(&blockingOptions{
|
|
queryOpts: &args.QueryOptions,
|
|
queryMeta: &reply.QueryMeta,
|
|
run: func(ws memdb.WatchSet, stateStore *state.StateStore) error {
|
|
|
|
// Perform the state query to get an iterator.
|
|
iter, err := stateStore.GetVariablesByNamespaceAndPrefix(ws, args.RequestNamespace(), args.Prefix)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Generate the tokenizer to use for pagination using namespace and
|
|
// ID to ensure complete uniqueness.
|
|
tokenizer := paginator.NewStructsTokenizer(iter,
|
|
paginator.StructsTokenizerOptions{
|
|
WithNamespace: true,
|
|
WithID: true,
|
|
},
|
|
)
|
|
|
|
filters := []paginator.Filter{
|
|
paginator.GenericFilter{
|
|
Allow: func(raw interface{}) (bool, error) {
|
|
v := raw.(*structs.VariableEncrypted)
|
|
if !strings.HasPrefix(v.Path, args.Prefix) {
|
|
return false, nil
|
|
}
|
|
err := sv.authorize(aclObj, claims, v.Namespace, acl.PolicyList, v.Path)
|
|
return err == nil, nil
|
|
},
|
|
},
|
|
}
|
|
|
|
// Set up our output after we have checked the error.
|
|
var svs []*structs.VariableMetadata
|
|
|
|
// Build the paginator. This includes the function that is
|
|
// responsible for appending a variable to the variables
|
|
// stubs slice.
|
|
paginatorImpl, err := paginator.NewPaginator(iter, tokenizer, filters, args.QueryOptions,
|
|
func(raw interface{}) error {
|
|
sv := raw.(*structs.VariableEncrypted)
|
|
svStub := sv.VariableMetadata
|
|
svs = append(svs, &svStub)
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return structs.NewErrRPCCodedf(
|
|
http.StatusBadRequest, "failed to create result paginator: %v", err)
|
|
}
|
|
|
|
// Calling page populates our output variable stub array as well as
|
|
// returns the next token.
|
|
nextToken, err := paginatorImpl.Page()
|
|
if err != nil {
|
|
return structs.NewErrRPCCodedf(
|
|
http.StatusBadRequest, "failed to read result page: %v", err)
|
|
}
|
|
|
|
// Populate the reply.
|
|
reply.Data = svs
|
|
reply.NextToken = nextToken
|
|
|
|
// Use the index table to populate the query meta as we have no way
|
|
// of tracking the max index on deletes.
|
|
return sv.srv.setReplyQueryMeta(stateStore, state.TableVariables, &reply.QueryMeta)
|
|
},
|
|
})
|
|
}
|
|
|
|
// listAllVariables is used to list variables held within
|
|
// state where the caller has used the namespace wildcard identifier.
|
|
func (sv *Variables) listAllVariables(
|
|
args *structs.VariablesListRequest,
|
|
reply *structs.VariablesListResponse) error {
|
|
|
|
// Perform token resolution. The request already goes through forwarding
|
|
// and metrics setup before being called.
|
|
aclObj, claims, err := sv.authenticate(args.QueryOptions)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Set up and return the blocking query.
|
|
return sv.srv.blockingRPC(&blockingOptions{
|
|
queryOpts: &args.QueryOptions,
|
|
queryMeta: &reply.QueryMeta,
|
|
run: func(ws memdb.WatchSet, stateStore *state.StateStore) error {
|
|
|
|
// Get all the variables stored within state.
|
|
iter, err := stateStore.Variables(ws)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var svs []*structs.VariableMetadata
|
|
|
|
// Generate the tokenizer to use for pagination using namespace and
|
|
// ID to ensure complete uniqueness.
|
|
tokenizer := paginator.NewStructsTokenizer(iter,
|
|
paginator.StructsTokenizerOptions{
|
|
WithNamespace: true,
|
|
WithID: true,
|
|
})
|
|
|
|
filters := []paginator.Filter{
|
|
paginator.GenericFilter{
|
|
Allow: func(raw interface{}) (bool, error) {
|
|
v := raw.(*structs.VariableEncrypted)
|
|
if !strings.HasPrefix(v.Path, args.Prefix) {
|
|
return false, nil
|
|
}
|
|
err := sv.authorize(aclObj, claims, v.Namespace, acl.PolicyList, v.Path)
|
|
return err == nil, nil
|
|
},
|
|
},
|
|
}
|
|
|
|
// Build the paginator. This includes the function that is
|
|
// responsible for appending a variable to the stubs array.
|
|
paginatorImpl, err := paginator.NewPaginator(iter, tokenizer, filters, args.QueryOptions,
|
|
func(raw interface{}) error {
|
|
v := raw.(*structs.VariableEncrypted)
|
|
svStub := v.VariableMetadata
|
|
svs = append(svs, &svStub)
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return structs.NewErrRPCCodedf(
|
|
http.StatusBadRequest, "failed to create result paginator: %v", err)
|
|
}
|
|
|
|
// Calling page populates our output variable stubs array as well as
|
|
// returns the next token.
|
|
nextToken, err := paginatorImpl.Page()
|
|
if err != nil {
|
|
return structs.NewErrRPCCodedf(
|
|
http.StatusBadRequest, "failed to read result page: %v", err)
|
|
}
|
|
|
|
// Populate the reply.
|
|
reply.Data = svs
|
|
reply.NextToken = nextToken
|
|
|
|
// Use the index table to populate the query meta as we have no way
|
|
// of tracking the max index on deletes.
|
|
return sv.srv.setReplyQueryMeta(stateStore, state.TableVariables, &reply.QueryMeta)
|
|
},
|
|
})
|
|
}
|
|
|
|
func (sv *Variables) encrypt(v *structs.VariableDecrypted) (*structs.VariableEncrypted, error) {
|
|
b, err := json.Marshal(v.Items)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ev := structs.VariableEncrypted{
|
|
VariableMetadata: v.VariableMetadata,
|
|
}
|
|
ev.Data, ev.KeyID, err = sv.encrypter.Encrypt(b)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &ev, nil
|
|
}
|
|
|
|
func (sv *Variables) decrypt(v *structs.VariableEncrypted) (*structs.VariableDecrypted, error) {
|
|
b, err := sv.encrypter.Decrypt(v.Data, v.KeyID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
dv := structs.VariableDecrypted{
|
|
VariableMetadata: v.VariableMetadata,
|
|
}
|
|
dv.Items = make(map[string]string)
|
|
err = json.Unmarshal(b, &dv.Items)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &dv, nil
|
|
}
|
|
|
|
// handleMixedAuthEndpoint is a helper to handle auth on RPC endpoints that can
|
|
// either be called by external clients or by workload identity
|
|
func (sv *Variables) handleMixedAuthEndpoint(args structs.QueryOptions, cap, pathOrPrefix string) (*acl.ACL, *structs.IdentityClaims, error) {
|
|
|
|
aclObj, claims, err := sv.authenticate(args)
|
|
if err != nil {
|
|
return aclObj, claims, err
|
|
}
|
|
err = sv.authorize(aclObj, claims, args.RequestNamespace(), cap, pathOrPrefix)
|
|
if err != nil {
|
|
return aclObj, claims, err
|
|
}
|
|
|
|
return aclObj, claims, nil
|
|
}
|
|
|
|
func (sv *Variables) authenticate(args structs.QueryOptions) (*acl.ACL, *structs.IdentityClaims, error) {
|
|
|
|
// Perform the initial token resolution.
|
|
aclObj, err := sv.srv.ResolveToken(args.AuthToken)
|
|
if err == nil {
|
|
return aclObj, nil, nil
|
|
}
|
|
if helper.IsUUID(args.AuthToken) {
|
|
// early return for ErrNotFound or other errors if it's formed
|
|
// like an ACLToken.SecretID
|
|
return nil, nil, err
|
|
}
|
|
|
|
// Attempt to verify the token as a JWT with a workload
|
|
// identity claim
|
|
claims, err := sv.srv.VerifyClaim(args.AuthToken)
|
|
if err != nil {
|
|
metrics.IncrCounter([]string{
|
|
"nomad", "variables", "invalid_allocation_identity"}, 1)
|
|
sv.logger.Trace("allocation identity was not valid", "error", err)
|
|
return nil, nil, structs.ErrPermissionDenied
|
|
}
|
|
return nil, claims, nil
|
|
}
|
|
|
|
func (sv *Variables) authorize(aclObj *acl.ACL, claims *structs.IdentityClaims, ns, cap, pathOrPrefix string) error {
|
|
|
|
if aclObj == nil && claims == nil {
|
|
return nil // ACLs aren't enabled
|
|
}
|
|
|
|
// Perform normal ACL validation. If the ACL object is nil, that means we're
|
|
// working with an identity claim.
|
|
if aclObj != nil {
|
|
if !aclObj.AllowVariableOperation(ns, pathOrPrefix, cap) {
|
|
return structs.ErrPermissionDenied
|
|
}
|
|
return nil
|
|
}
|
|
|
|
if claims != nil {
|
|
// The workload identity gets access to paths that match its
|
|
// identity, without having to go thru the ACL system
|
|
err := sv.authValidatePrefix(claims, ns, pathOrPrefix)
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
|
|
// If the workload identity doesn't match the implicit permissions
|
|
// given to paths, check for its attached ACL policies
|
|
aclObj, err = sv.srv.ResolveClaims(claims)
|
|
if err != nil {
|
|
return err // this only returns an error when the state store has gone wrong
|
|
}
|
|
if aclObj != nil && aclObj.AllowVariableOperation(
|
|
ns, pathOrPrefix, cap) {
|
|
return nil
|
|
}
|
|
}
|
|
return structs.ErrPermissionDenied
|
|
}
|
|
|
|
// authValidatePrefix asserts that the requested path is valid for
|
|
// this allocation
|
|
func (sv *Variables) authValidatePrefix(claims *structs.IdentityClaims, ns, pathOrPrefix string) error {
|
|
|
|
store, err := sv.srv.fsm.State().Snapshot()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
alloc, err := store.AllocByID(nil, claims.AllocationID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if alloc == nil || alloc.Job == nil {
|
|
return fmt.Errorf("allocation does not exist")
|
|
}
|
|
if alloc.Job.Namespace != ns {
|
|
return fmt.Errorf("allocation is in another namespace")
|
|
}
|
|
|
|
parts := strings.Split(pathOrPrefix, "/")
|
|
expect := []string{"nomad", "jobs", claims.JobID, alloc.TaskGroup, claims.TaskName}
|
|
if len(parts) > len(expect) {
|
|
return structs.ErrPermissionDenied
|
|
}
|
|
|
|
for idx, part := range parts {
|
|
if part != expect[idx] {
|
|
return structs.ErrPermissionDenied
|
|
}
|
|
}
|
|
return nil
|
|
}
|