Pre forwarding authentication (#15417)
Upcoming work to instrument the rate of RPC requests by consumer (and eventually rate limit) require that we authenticate a RPC request before forwarding. Add a new top-level `Authenticate` method to the server and have it return an `AuthenticatedIdentity` struct. RPC handlers will use the relevant fields of this identity for performing authorization. This changeset includes: * The main implementation of `Authenticate` * Provide a new RPC `ACL.WhoAmI` for debugging authentication. This endpoint returns the same `AuthenticatedIdentity` that will be used by RPC handlers. At some point we might want to give this an equivalent HTTP endpoint but I didn't want to add that to our public API until some of the other Workload Identity work is solidified, especially if we don't need it yet. * A full coverage test of the `Authenticate` method. This sets up two server nodes with mTLS and ACLs, some tokens, and some allocations with workload identities. * Wire up an example of using `Authenticate` in the `Namespace.Upsert` RPC and see how authorization happens after forwarding. * A new semgrep rule for `Authenticate`, which we'll need to update once we're ready to wire up more RPC endpoints with authorization steps.
This commit is contained in:
parent
ce0ffdd077
commit
e0fddee386
|
@ -62,6 +62,12 @@ rules:
|
|||
...
|
||||
return $A.deregister(...)
|
||||
...
|
||||
# Pattern used by Authenticate method.
|
||||
# TODO: add authorization steps as well.
|
||||
- pattern-not-inside: |
|
||||
...
|
||||
... := $A.$B.Authenticate($A.ctx, args.AuthToken)
|
||||
...
|
||||
- metavariable-pattern:
|
||||
metavariable: $METHOD
|
||||
patterns:
|
||||
|
|
5
helper/tlsutil/testdata/nomad-foo-client-key.pem
vendored
Normal file
5
helper/tlsutil/testdata/nomad-foo-client-key.pem
vendored
Normal file
|
@ -0,0 +1,5 @@
|
|||
-----BEGIN EC PRIVATE KEY-----
|
||||
MHcCAQEEIBxaGxJxJXnAXVmb8E3ALsWqva9F01R0cr/1Ap75YyeAoAoGCCqGSM49
|
||||
AwEHoUQDQgAEXSLJPcA7b9P6y0Ls7zR4997+F3251hwEUn8qR01AEVGjYrAjk/ns
|
||||
qaq7P9y/w4k9TvhWaq9/L6id468a0/VWCw==
|
||||
-----END EC PRIVATE KEY-----
|
15
helper/tlsutil/testdata/nomad-foo-client.pem
vendored
Normal file
15
helper/tlsutil/testdata/nomad-foo-client.pem
vendored
Normal file
|
@ -0,0 +1,15 @@
|
|||
-----BEGIN CERTIFICATE-----
|
||||
MIICWTCCAgCgAwIBAgIQOW7/CDB2IhlMyfh16erD/jAKBggqhkjOPQQDAjB4MQsw
|
||||
CQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNU2FuIEZy
|
||||
YW5jaXNjbzESMBAGA1UEChMJSGFzaGlDb3JwMQ4wDAYDVQQLEwVOb21hZDEYMBYG
|
||||
A1UEAxMPbm9tYWQuaGFzaGljb3JwMCAXDTIyMTEyOTE5MjY0MloYDzIxMjIxMTA1
|
||||
MTkyNjQyWjAhMR8wHQYDVQQDExZjbGllbnQucmVnaW9uRm9vLm5vbWFkMFkwEwYH
|
||||
KoZIzj0CAQYIKoZIzj0DAQcDQgAEXSLJPcA7b9P6y0Ls7zR4997+F3251hwEUn8q
|
||||
R01AEVGjYrAjk/nsqaq7P9y/w4k9TvhWaq9/L6id468a0/VWC6OBwDCBvTAOBgNV
|
||||
HQ8BAf8EBAMCBaAwHQYDVR0lBBYwFAYIKwYBBQUHAwIGCCsGAQUFBwMBMAwGA1Ud
|
||||
EwEB/wQCMAAwKQYDVR0OBCIEII1J2DmAAcPAaNLFlxFpdBzjhRFRd9E9fedoz9I8
|
||||
vHPPMB8GA1UdIwQYMBaAFKJkNK006jVs/eYf4w00jciQj2MEMDIGA1UdEQQrMCmC
|
||||
FmNsaWVudC5yZWdpb25Gb28ubm9tYWSCCWxvY2FsaG9zdIcEfwAAATAKBggqhkjO
|
||||
PQQDAgNHADBEAiAXzlb98iqyXvtlkThR13ojgjwjP25JBysDKf4vnXjQuwIgFpkB
|
||||
0B7bPy5VNIAVsw6n5ocvsB7w0rgBPJyS3I2YCi0=
|
||||
-----END CERTIFICATE-----
|
138
nomad/acl.go
138
nomad/acl.go
|
@ -1,16 +1,133 @@
|
|||
package nomad
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
metrics "github.com/armon/go-metrics"
|
||||
lru "github.com/hashicorp/golang-lru"
|
||||
"github.com/hashicorp/nomad/acl"
|
||||
"github.com/hashicorp/nomad/helper"
|
||||
"github.com/hashicorp/nomad/nomad/state"
|
||||
"github.com/hashicorp/nomad/nomad/structs"
|
||||
)
|
||||
|
||||
// Authenticate extracts an AuthenticatedIdentity from the request context or
|
||||
// provided token. The caller can extract an acl.ACL, WorkloadIdentity, or other
|
||||
// identifying token to use for authorization.
|
||||
//
|
||||
// Note: when called on the follower we'll be making stale queries, so it's
|
||||
// possible if the follower is behind that the leader will get a different value
|
||||
// if an ACL token or allocation's WI has just been created.
|
||||
func (s *Server) Authenticate(ctx *RPCContext, secretID string) (*structs.AuthenticatedIdentity, error) {
|
||||
|
||||
// Previously-connected clients will have a NodeID set and will be a large
|
||||
// number of the RPCs sent, so we can fast path this case
|
||||
if ctx != nil && ctx.NodeID != "" {
|
||||
return &structs.AuthenticatedIdentity{ClientID: ctx.NodeID}, nil
|
||||
}
|
||||
|
||||
// get the user ACLToken or anonymous token
|
||||
aclToken, err := s.ResolveSecretToken(secretID)
|
||||
|
||||
switch {
|
||||
case err == nil:
|
||||
// If ACLs are disabled or we have a non-anonymous token, return that.
|
||||
if aclToken == nil || aclToken != structs.AnonymousACLToken {
|
||||
return &structs.AuthenticatedIdentity{ACLToken: aclToken}, nil
|
||||
}
|
||||
|
||||
case errors.Is(err, structs.ErrTokenExpired):
|
||||
return nil, err
|
||||
|
||||
case errors.Is(err, structs.ErrTokenInvalid):
|
||||
// if it's not a UUID it might be an identity claim
|
||||
claims, err := s.VerifyClaim(secretID)
|
||||
if err != nil {
|
||||
// we already know the token wasn't valid for an ACL in the state
|
||||
// store, so if we get an error at this point we have an invalid
|
||||
// token and there are no other options but to bail out
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &structs.AuthenticatedIdentity{Claims: claims}, nil
|
||||
|
||||
case errors.Is(err, structs.ErrTokenNotFound):
|
||||
// Check if the secret ID is the leader's secret ID, in which case treat
|
||||
// it as a management token.
|
||||
leaderAcl := s.getLeaderAcl()
|
||||
if leaderAcl != "" && secretID == leaderAcl {
|
||||
aclToken = structs.LeaderACLToken
|
||||
} else {
|
||||
// Otherwise, see if the secret ID belongs to a node. We should
|
||||
// reach this point only on first connection.
|
||||
node, err := s.State().NodeBySecretID(nil, secretID)
|
||||
if err != nil {
|
||||
// this is a go-memdb error; shouldn't happen
|
||||
return nil, fmt.Errorf("could not resolve node secret: %w", err)
|
||||
}
|
||||
if node != nil {
|
||||
return &structs.AuthenticatedIdentity{ClientID: node.ID}, nil
|
||||
}
|
||||
}
|
||||
|
||||
default: // any other error
|
||||
return nil, fmt.Errorf("could not resolve user: %w", err)
|
||||
|
||||
}
|
||||
|
||||
// If there's no context we're in a "static" handler which only happens for
|
||||
// cases where the leader is making RPCs internally (volumewatcher and
|
||||
// deploymentwatcher)
|
||||
if ctx == nil {
|
||||
return &structs.AuthenticatedIdentity{ACLToken: aclToken}, nil
|
||||
}
|
||||
|
||||
// At this point we either have an anonymous token or an invalid one.
|
||||
// Unlike clients that provide their Node ID on first connection, server
|
||||
// RPCs don't include an ID for the server so we identify servers by cert
|
||||
// and IP address.
|
||||
identity := &structs.AuthenticatedIdentity{ACLToken: aclToken}
|
||||
if ctx.TLS {
|
||||
identity.TLSName = ctx.Certificate().Subject.CommonName
|
||||
}
|
||||
|
||||
var remoteAddr *net.TCPAddr
|
||||
var ok bool
|
||||
if ctx.Session != nil {
|
||||
remoteAddr, ok = ctx.Session.RemoteAddr().(*net.TCPAddr)
|
||||
if !ok {
|
||||
return nil, errors.New("session address was not a TCP address")
|
||||
}
|
||||
}
|
||||
if remoteAddr == nil && ctx.Conn != nil {
|
||||
remoteAddr, ok = ctx.Conn.RemoteAddr().(*net.TCPAddr)
|
||||
if !ok {
|
||||
return nil, errors.New("session address was not a TCP address")
|
||||
}
|
||||
}
|
||||
if remoteAddr != nil {
|
||||
identity.RemoteIP = remoteAddr.IP
|
||||
return identity, nil
|
||||
}
|
||||
|
||||
s.logger.Error("could not authenticate RPC request or determine remote address")
|
||||
return nil, structs.ErrPermissionDenied
|
||||
}
|
||||
|
||||
func (s *Server) ResolveACL(aclToken *structs.ACLToken) (*acl.ACL, error) {
|
||||
if !s.config.ACLEnabled {
|
||||
return nil, nil
|
||||
}
|
||||
snap, err := s.fsm.State().Snapshot()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resolveACLFromToken(snap, s.aclCache, aclToken)
|
||||
}
|
||||
|
||||
// ResolveToken is used to translate an ACL Token Secret ID into
|
||||
// an ACL object, nil if ACLs are disabled, or an error.
|
||||
func (s *Server) ResolveToken(secretID string) (*acl.ACL, error) {
|
||||
|
@ -106,6 +223,12 @@ func resolveTokenFromSnapshotCache(snap *state.StateSnapshot, cache *lru.TwoQueu
|
|||
}
|
||||
}
|
||||
|
||||
return resolveACLFromToken(snap, cache, token)
|
||||
|
||||
}
|
||||
|
||||
func resolveACLFromToken(snap *state.StateSnapshot, cache *lru.TwoQueueCache, token *structs.ACLToken) (*acl.ACL, error) {
|
||||
|
||||
// Check if this is a management token
|
||||
if token.Type == structs.ACLManagementToken {
|
||||
return acl.ManagementACL, nil
|
||||
|
@ -185,18 +308,20 @@ func (s *Server) ResolveSecretToken(secretID string) (*structs.ACLToken, error)
|
|||
}
|
||||
defer metrics.MeasureSince([]string{"nomad", "acl", "resolveSecretToken"}, time.Now())
|
||||
|
||||
if secretID == "" {
|
||||
return structs.AnonymousACLToken, nil
|
||||
}
|
||||
if !helper.IsUUID(secretID) {
|
||||
return nil, structs.ErrTokenInvalid
|
||||
}
|
||||
|
||||
snap, err := s.fsm.State().Snapshot()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Lookup the ACL Token
|
||||
var token *structs.ACLToken
|
||||
// Handle anonymous requests
|
||||
if secretID == "" {
|
||||
token = structs.AnonymousACLToken
|
||||
} else {
|
||||
token, err = snap.ACLTokenBySecretID(nil, secretID)
|
||||
token, err := snap.ACLTokenBySecretID(nil, secretID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -206,7 +331,6 @@ func (s *Server) ResolveSecretToken(secretID string) (*structs.ACLToken, error)
|
|||
if token.IsExpired(time.Now().UTC()) {
|
||||
return nil, structs.ErrTokenExpired
|
||||
}
|
||||
}
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
|
|
@ -1964,3 +1964,25 @@ func (a *ACL) GetAuthMethods(
|
|||
}},
|
||||
)
|
||||
}
|
||||
|
||||
// WhoAmI is a RPC for debugging authentication. This endpoint returns the same
|
||||
// AuthenticatedIdentity that will be used by RPC handlers.
|
||||
//
|
||||
// TODO: At some point we might want to give this an equivalent HTTP endpoint
|
||||
// once other Workload Identity work is solidified
|
||||
func (a *ACL) WhoAmI(args *structs.GenericRequest, reply *structs.ACLWhoAmIResponse) error {
|
||||
|
||||
identity, err := a.srv.Authenticate(a.ctx, args.AuthToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
args.SetIdentity(identity)
|
||||
|
||||
if done, err := a.srv.forward("ACL.WhoAmI", args, args, reply); done {
|
||||
return err
|
||||
}
|
||||
defer metrics.MeasureSince([]string{"nomad", "acl", "whoami"}, time.Now())
|
||||
|
||||
reply.Identity = args.GetIdentity()
|
||||
return nil
|
||||
}
|
||||
|
|
|
@ -1,20 +1,302 @@
|
|||
package nomad
|
||||
|
||||
import (
|
||||
"path"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
msgpackrpc "github.com/hashicorp/net-rpc-msgpackrpc"
|
||||
"github.com/hashicorp/nomad/acl"
|
||||
"github.com/hashicorp/nomad/ci"
|
||||
"github.com/hashicorp/nomad/helper/pointer"
|
||||
"github.com/hashicorp/nomad/helper/uuid"
|
||||
"github.com/hashicorp/nomad/nomad/mock"
|
||||
"github.com/hashicorp/nomad/nomad/structs"
|
||||
"github.com/hashicorp/nomad/nomad/structs/config"
|
||||
"github.com/hashicorp/nomad/testutil"
|
||||
"github.com/shoenig/test"
|
||||
"github.com/shoenig/test/must"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAuthenticate_mTLS(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
|
||||
// Set up a cluster with mTLS and ACLs
|
||||
|
||||
dir := t.TempDir()
|
||||
|
||||
tlsCfg := &config.TLSConfig{
|
||||
EnableHTTP: true,
|
||||
EnableRPC: true,
|
||||
VerifyServerHostname: true,
|
||||
CAFile: "../helper/tlsutil/testdata/ca.pem",
|
||||
CertFile: "../helper/tlsutil/testdata/nomad-foo.pem",
|
||||
KeyFile: "../helper/tlsutil/testdata/nomad-foo-key.pem",
|
||||
}
|
||||
clientTLSCfg := tlsCfg.Copy()
|
||||
clientTLSCfg.CertFile = "../helper/tlsutil/testdata/nomad-foo-client.pem"
|
||||
clientTLSCfg.KeyFile = "../helper/tlsutil/testdata/nomad-foo-client-key.pem"
|
||||
|
||||
setCfg := func(name string, bootstrapExpect int) func(*Config) {
|
||||
return func(c *Config) {
|
||||
c.Region = "regionFoo"
|
||||
c.AuthoritativeRegion = "regionFoo"
|
||||
c.ACLEnabled = true
|
||||
c.BootstrapExpect = bootstrapExpect
|
||||
c.NumSchedulers = 0
|
||||
c.DevMode = false
|
||||
c.DataDir = path.Join(dir, name)
|
||||
c.TLSConfig = tlsCfg
|
||||
}
|
||||
}
|
||||
|
||||
leader, cleanupLeader := TestServer(t, setCfg("node1", 1))
|
||||
defer cleanupLeader()
|
||||
testutil.WaitForLeader(t, leader.RPC)
|
||||
|
||||
follower, cleanupFollower := TestServer(t, setCfg("node2", 0))
|
||||
defer cleanupFollower()
|
||||
|
||||
TestJoin(t, leader, follower)
|
||||
testutil.WaitForLeader(t, leader.RPC)
|
||||
|
||||
testutil.Wait(t, func() (bool, error) {
|
||||
keyset, err := follower.encrypter.activeKeySet()
|
||||
return keyset != nil, err
|
||||
})
|
||||
|
||||
rootToken := uuid.Generate()
|
||||
var bootstrapResp *structs.ACLTokenUpsertResponse
|
||||
|
||||
codec := rpcClientWithTLS(t, follower, tlsCfg)
|
||||
must.NoError(t, msgpackrpc.CallWithCodec(codec,
|
||||
"ACL.Bootstrap", &structs.ACLTokenBootstrapRequest{
|
||||
BootstrapSecret: rootToken,
|
||||
WriteRequest: structs.WriteRequest{Region: "regionFoo"},
|
||||
}, &bootstrapResp))
|
||||
must.NotNil(t, bootstrapResp)
|
||||
must.Len(t, 1, bootstrapResp.Tokens)
|
||||
rootAccessor := bootstrapResp.Tokens[0].AccessorID
|
||||
|
||||
// create some ACL tokens directly into raft so we can bypass RPC validation
|
||||
// around expiration times
|
||||
|
||||
token1 := mock.ACLToken()
|
||||
token2 := mock.ACLToken()
|
||||
expireTime := time.Now().Add(time.Second * -10)
|
||||
token2.ExpirationTime = &expireTime
|
||||
|
||||
_, _, err := leader.raftApply(structs.ACLTokenUpsertRequestType,
|
||||
&structs.ACLTokenUpsertRequest{Tokens: []*structs.ACLToken{token1, token2}})
|
||||
must.NoError(t, err)
|
||||
|
||||
// create a node so we can test client RPCs
|
||||
|
||||
node := mock.Node()
|
||||
nodeRegisterReq := &structs.NodeRegisterRequest{
|
||||
Node: node,
|
||||
WriteRequest: structs.WriteRequest{Region: "regionFoo"},
|
||||
}
|
||||
var nodeRegisterResp structs.NodeUpdateResponse
|
||||
|
||||
must.NoError(t, msgpackrpc.CallWithCodec(codec,
|
||||
"Node.Register", nodeRegisterReq, &nodeRegisterResp))
|
||||
must.NotNil(t, bootstrapResp)
|
||||
|
||||
// create some allocations so we can test WorkloadIdentity claims. we'll
|
||||
// create directly into raft so we can bypass RPC validation and the whole
|
||||
// eval, plan, etc. workflow.
|
||||
job := mock.Job()
|
||||
|
||||
_, _, err = leader.raftApply(structs.JobRegisterRequestType,
|
||||
&structs.JobRegisterRequest{Job: job})
|
||||
must.NoError(t, err)
|
||||
|
||||
alloc1 := mock.Alloc()
|
||||
alloc1.NodeID = node.ID
|
||||
alloc1.ClientStatus = structs.AllocClientStatusFailed
|
||||
alloc1.Job = job
|
||||
alloc1.JobID = job.ID
|
||||
|
||||
alloc2 := mock.Alloc()
|
||||
alloc2.NodeID = node.ID
|
||||
alloc2.Job = job
|
||||
alloc2.JobID = job.ID
|
||||
alloc2.ClientStatus = structs.AllocClientStatusRunning
|
||||
|
||||
claims1 := alloc1.ToTaskIdentityClaims(nil, "web")
|
||||
claims1Token, _, err := leader.encrypter.SignClaims(claims1)
|
||||
must.NoError(t, err, must.Sprint("could not sign claims"))
|
||||
|
||||
claims2 := alloc2.ToTaskIdentityClaims(nil, "web")
|
||||
claims2Token, _, err := leader.encrypter.SignClaims(claims2)
|
||||
must.NoError(t, err, must.Sprint("could not sign claims"))
|
||||
|
||||
planReq := &structs.ApplyPlanResultsRequest{
|
||||
AllocUpdateRequest: structs.AllocUpdateRequest{
|
||||
Alloc: []*structs.Allocation{alloc1, alloc2},
|
||||
Job: job,
|
||||
},
|
||||
}
|
||||
_, _, err = leader.raftApply(structs.ApplyPlanResultsRequestType, planReq)
|
||||
must.NoError(t, err)
|
||||
|
||||
testutil.WaitForResult(func() (bool, error) {
|
||||
store := follower.fsm.State()
|
||||
alloc, err := store.AllocByID(nil, alloc1.ID)
|
||||
return alloc != nil, err
|
||||
}, func(err error) {
|
||||
t.Fatalf("alloc was not replicated via raft: %v", err) // should never happen
|
||||
})
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
tlsCfg *config.TLSConfig
|
||||
stale bool
|
||||
testToken string
|
||||
expectAccessor string
|
||||
expectClientID string
|
||||
expectAllocID string
|
||||
expectTLSName string
|
||||
expectIP string
|
||||
expectErr string
|
||||
sendFromPeer *Server
|
||||
}{
|
||||
{
|
||||
name: "root token",
|
||||
tlsCfg: clientTLSCfg, // TODO: this is a mixed use cert
|
||||
testToken: rootToken,
|
||||
expectAccessor: rootAccessor,
|
||||
},
|
||||
{
|
||||
name: "from peer to leader without token", // ex. Eval.Dequeue
|
||||
tlsCfg: tlsCfg,
|
||||
expectTLSName: "regionFoo.nomad",
|
||||
expectAccessor: "anonymous",
|
||||
expectIP: follower.GetConfig().RPCAddr.IP.String(),
|
||||
sendFromPeer: follower,
|
||||
},
|
||||
{
|
||||
// note: this test is somewhat bogus because under test all the
|
||||
// servers share the same IP address with the RPC client
|
||||
name: "anonymous forwarded from peer to leader",
|
||||
tlsCfg: tlsCfg,
|
||||
expectAccessor: "anonymous",
|
||||
expectTLSName: "regionFoo.nomad",
|
||||
expectIP: "127.0.0.1",
|
||||
},
|
||||
{
|
||||
name: "invalid token",
|
||||
tlsCfg: clientTLSCfg,
|
||||
testToken: uuid.Generate(),
|
||||
expectTLSName: "regionFoo.nomad",
|
||||
expectIP: follower.GetConfig().RPCAddr.IP.String(),
|
||||
},
|
||||
{
|
||||
name: "expired token",
|
||||
tlsCfg: clientTLSCfg,
|
||||
testToken: uuid.Generate(),
|
||||
expectTLSName: "regionFoo.nomad",
|
||||
expectIP: follower.GetConfig().RPCAddr.IP.String(),
|
||||
},
|
||||
{
|
||||
name: "from peer to leader with leader ACL", // ex. core job GC
|
||||
tlsCfg: tlsCfg,
|
||||
testToken: leader.getLeaderAcl(),
|
||||
expectTLSName: "regionFoo.nomad",
|
||||
expectAccessor: "leader",
|
||||
expectIP: follower.GetConfig().RPCAddr.IP.String(),
|
||||
sendFromPeer: follower,
|
||||
},
|
||||
{
|
||||
name: "from client", // ex. Node.GetAllocs
|
||||
tlsCfg: clientTLSCfg,
|
||||
testToken: node.SecretID,
|
||||
expectClientID: node.ID,
|
||||
},
|
||||
{
|
||||
name: "from failed workload", // ex. Variables.List
|
||||
tlsCfg: clientTLSCfg,
|
||||
testToken: claims1Token,
|
||||
expectErr: "rpc error: allocation is terminal",
|
||||
},
|
||||
{
|
||||
name: "from running workload", // ex. Variables.List
|
||||
tlsCfg: clientTLSCfg,
|
||||
testToken: claims2Token,
|
||||
expectAllocID: alloc2.ID,
|
||||
},
|
||||
{
|
||||
name: "valid user token",
|
||||
tlsCfg: clientTLSCfg,
|
||||
testToken: token1.SecretID,
|
||||
expectAccessor: token1.AccessorID,
|
||||
},
|
||||
{
|
||||
name: "expired user token",
|
||||
tlsCfg: clientTLSCfg,
|
||||
testToken: token2.SecretID,
|
||||
expectErr: "rpc error: ACL token expired",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
|
||||
req := &structs.GenericRequest{
|
||||
QueryOptions: structs.QueryOptions{
|
||||
Region: "regionFoo",
|
||||
AllowStale: tc.stale,
|
||||
AuthToken: tc.testToken,
|
||||
},
|
||||
}
|
||||
var resp structs.ACLWhoAmIResponse
|
||||
var err error
|
||||
|
||||
if tc.sendFromPeer != nil {
|
||||
aclEndpoint := NewACLEndpoint(tc.sendFromPeer, nil)
|
||||
err = aclEndpoint.WhoAmI(req, &resp)
|
||||
} else {
|
||||
err = msgpackrpc.CallWithCodec(codec, "ACL.WhoAmI", req, &resp)
|
||||
}
|
||||
|
||||
if tc.expectErr != "" {
|
||||
must.EqError(t, err, tc.expectErr)
|
||||
return
|
||||
}
|
||||
|
||||
must.NoError(t, err)
|
||||
must.NotNil(t, resp)
|
||||
must.NotNil(t, resp.Identity)
|
||||
|
||||
if tc.expectAccessor != "" {
|
||||
must.NotNil(t, resp.Identity.ACLToken, must.Sprint("expected ACL token"))
|
||||
test.Eq(t, tc.expectAccessor, resp.Identity.ACLToken.AccessorID,
|
||||
must.Sprint("expected ACL token accessor ID"))
|
||||
}
|
||||
|
||||
test.Eq(t, tc.expectClientID, resp.Identity.ClientID,
|
||||
must.Sprint("expected client ID"))
|
||||
|
||||
if tc.expectAllocID != "" {
|
||||
must.NotNil(t, resp.Identity.Claims, must.Sprint("expected claims"))
|
||||
test.Eq(t, tc.expectAllocID, resp.Identity.Claims.AllocationID,
|
||||
must.Sprint("expected workload identity"))
|
||||
}
|
||||
|
||||
test.Eq(t, tc.expectTLSName, resp.Identity.TLSName, must.Sprint("expected TLS name"))
|
||||
|
||||
if tc.expectIP == "" {
|
||||
test.Nil(t, resp.Identity.RemoteIP, must.Sprint("expected no remote IP"))
|
||||
} else {
|
||||
test.Eq(t, tc.expectIP, resp.Identity.RemoteIP.String())
|
||||
}
|
||||
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveACLToken(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
|
||||
|
|
|
@ -25,6 +25,13 @@ func NewNamespaceEndpoint(srv *Server, ctx *RPCContext) *Namespace {
|
|||
// UpsertNamespaces is used to upsert a set of namespaces
|
||||
func (n *Namespace) UpsertNamespaces(args *structs.NamespaceUpsertRequest,
|
||||
reply *structs.GenericResponse) error {
|
||||
|
||||
identity, err := n.srv.Authenticate(n.ctx, args.AuthToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
args.SetIdentity(identity)
|
||||
|
||||
args.Region = n.srv.config.AuthoritativeRegion
|
||||
if done, err := n.srv.forward("Namespace.UpsertNamespaces", args, args, reply); done {
|
||||
return err
|
||||
|
@ -32,7 +39,7 @@ func (n *Namespace) UpsertNamespaces(args *structs.NamespaceUpsertRequest,
|
|||
defer metrics.MeasureSince([]string{"nomad", "namespace", "upsert_namespaces"}, time.Now())
|
||||
|
||||
// Check management permissions
|
||||
if aclObj, err := n.srv.ResolveToken(args.AuthToken); err != nil {
|
||||
if aclObj, err := n.srv.ResolveACL(args.GetIdentity().GetACLToken()); err != nil {
|
||||
return err
|
||||
} else if aclObj != nil && !aclObj.IsManagement() {
|
||||
return structs.ErrPermissionDenied
|
||||
|
|
|
@ -31,6 +31,7 @@ import (
|
|||
"github.com/hashicorp/nomad/testutil"
|
||||
"github.com/hashicorp/raft"
|
||||
"github.com/hashicorp/yamux"
|
||||
"github.com/shoenig/test/must"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
@ -38,6 +39,7 @@ import (
|
|||
// rpcClient is a test helper method to return a ClientCodec to use to make rpc
|
||||
// calls to the passed server.
|
||||
func rpcClient(t *testing.T, s *Server) rpc.ClientCodec {
|
||||
t.Helper()
|
||||
addr := s.config.RPCAddr
|
||||
conn, err := net.DialTimeout("tcp", addr.String(), time.Second)
|
||||
if err != nil {
|
||||
|
@ -48,6 +50,36 @@ func rpcClient(t *testing.T, s *Server) rpc.ClientCodec {
|
|||
return pool.NewClientCodec(conn)
|
||||
}
|
||||
|
||||
// rpcClientWithTLS is a test helper method to return a ClientCodec to use to
|
||||
// make RPC calls to the passed server via mTLS
|
||||
func rpcClientWithTLS(t *testing.T, srv *Server, cfg *config.TLSConfig) rpc.ClientCodec {
|
||||
t.Helper()
|
||||
|
||||
// configure TLS, ignoring client-side validation
|
||||
tlsConf, err := tlsutil.NewTLSConfiguration(cfg, true, true)
|
||||
must.NoError(t, err)
|
||||
outTLSConf, err := tlsConf.OutgoingTLSConfig()
|
||||
must.NoError(t, err)
|
||||
outTLSConf.InsecureSkipVerify = true
|
||||
|
||||
// make the TCP connection
|
||||
conn, err := net.DialTimeout("tcp", srv.config.RPCAddr.String(), time.Second)
|
||||
|
||||
// write the TLS byte to set the mode
|
||||
_, err = conn.Write([]byte{byte(pool.RpcTLS)})
|
||||
must.NoError(t, err)
|
||||
|
||||
// connect w/ TLS
|
||||
tlsConn := tls.Client(conn, outTLSConf)
|
||||
must.NoError(t, tlsConn.Handshake())
|
||||
|
||||
// write the Nomad RPC byte to set the mode
|
||||
_, err = tlsConn.Write([]byte{byte(pool.RpcNomad)})
|
||||
must.NoError(t, err)
|
||||
|
||||
return pool.NewClientCodec(tlsConn)
|
||||
}
|
||||
|
||||
func TestRPC_forwardLeader(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
|
||||
|
|
|
@ -843,3 +843,8 @@ type ACLAuthMethodDeleteRequest struct {
|
|||
type ACLAuthMethodDeleteResponse struct {
|
||||
WriteMeta
|
||||
}
|
||||
|
||||
type ACLWhoAmIResponse struct {
|
||||
Identity *AuthenticatedIdentity
|
||||
QueryMeta
|
||||
}
|
||||
|
|
|
@ -13,6 +13,7 @@ const (
|
|||
errNoRegionPath = "No path to region"
|
||||
errTokenNotFound = "ACL token not found"
|
||||
errTokenExpired = "ACL token expired"
|
||||
errTokenInvalid = "ACL token is invalid" // not a UUID
|
||||
errPermissionDenied = "Permission denied"
|
||||
errJobRegistrationDisabled = "Job registration, dispatch, and scale are disabled by the scheduler configuration"
|
||||
errNoNodeConn = "No path to node"
|
||||
|
@ -50,6 +51,7 @@ var (
|
|||
ErrNoRegionPath = errors.New(errNoRegionPath)
|
||||
ErrTokenNotFound = errors.New(errTokenNotFound)
|
||||
ErrTokenExpired = errors.New(errTokenExpired)
|
||||
ErrTokenInvalid = errors.New(errTokenInvalid)
|
||||
ErrPermissionDenied = errors.New(errPermissionDenied)
|
||||
ErrJobRegistrationDisabled = errors.New(errJobRegistrationDisabled)
|
||||
ErrNoNodeConn = errors.New(errNoNodeConn)
|
||||
|
|
|
@ -293,6 +293,8 @@ type QueryOptions struct {
|
|||
// Reverse is used to reverse the default order of list results.
|
||||
Reverse bool
|
||||
|
||||
identity *AuthenticatedIdentity
|
||||
|
||||
InternalRpcInfo
|
||||
}
|
||||
|
||||
|
@ -339,6 +341,14 @@ func (q QueryOptions) AllowStaleRead() bool {
|
|||
return q.AllowStale
|
||||
}
|
||||
|
||||
func (q *QueryOptions) SetIdentity(identity *AuthenticatedIdentity) {
|
||||
q.identity = identity
|
||||
}
|
||||
|
||||
func (q QueryOptions) GetIdentity() *AuthenticatedIdentity {
|
||||
return q.identity
|
||||
}
|
||||
|
||||
// AgentPprofRequest is used to request a pprof report for a given node.
|
||||
type AgentPprofRequest struct {
|
||||
// ReqType specifies the profile to use
|
||||
|
@ -399,6 +409,8 @@ type WriteRequest struct {
|
|||
// IdempotencyToken can be used to ensure the write is idempotent.
|
||||
IdempotencyToken string
|
||||
|
||||
identity *AuthenticatedIdentity
|
||||
|
||||
InternalRpcInfo
|
||||
}
|
||||
|
||||
|
@ -435,6 +447,41 @@ func (w WriteRequest) AllowStaleRead() bool {
|
|||
return false
|
||||
}
|
||||
|
||||
func (w *WriteRequest) SetIdentity(identity *AuthenticatedIdentity) {
|
||||
w.identity = identity
|
||||
}
|
||||
|
||||
func (w WriteRequest) GetIdentity() *AuthenticatedIdentity {
|
||||
return w.identity
|
||||
}
|
||||
|
||||
// AuthenticatedIdentity is returned by the Authenticate method on server to
|
||||
// return a wrapper around the various elements that can be resolved as an
|
||||
// identity. RPC handlers will use the relevant fields for performing
|
||||
// authorization.
|
||||
type AuthenticatedIdentity struct {
|
||||
ACLToken *ACLToken
|
||||
Claims *IdentityClaims
|
||||
ClientID string
|
||||
ServerID string
|
||||
TLSName string
|
||||
RemoteIP net.IP
|
||||
}
|
||||
|
||||
func (ai *AuthenticatedIdentity) GetACLToken() *ACLToken {
|
||||
if ai == nil {
|
||||
return nil
|
||||
}
|
||||
return ai.ACLToken
|
||||
}
|
||||
|
||||
func (ai *AuthenticatedIdentity) GetClaims() *IdentityClaims {
|
||||
if ai == nil {
|
||||
return nil
|
||||
}
|
||||
return ai.Claims
|
||||
}
|
||||
|
||||
// QueryMeta allows a query response to include potentially
|
||||
// useful metadata about a query
|
||||
type QueryMeta struct {
|
||||
|
@ -12086,6 +12133,14 @@ var (
|
|||
Policies: []string{"anonymous"},
|
||||
Global: false,
|
||||
}
|
||||
|
||||
// LeaderACLToken is used to represent a leader's own token; this object
|
||||
// never gets used except on the leader
|
||||
LeaderACLToken = &ACLToken{
|
||||
AccessorID: "leader",
|
||||
Name: "Leader Token",
|
||||
Type: ACLManagementToken,
|
||||
}
|
||||
)
|
||||
|
||||
type ACLTokenListStub struct {
|
||||
|
|
Loading…
Reference in a new issue