From f9cf36d43aa5b16c53a07d980d368c14fabf7ac3 Mon Sep 17 00:00:00 2001 From: Tim Gross Date: Thu, 25 Feb 2021 16:41:00 -0500 Subject: [PATCH] HTTP API support for 'nomad ui -login' Endpoints for requesting and exchanging one-time tokens via the HTTP API. Includes documentation updates. --- api/acl.go | 50 +++++++++++ api/acl_test.go | 33 +++++++ command/agent/acl_endpoint.go | 39 +++++++++ command/agent/acl_endpoint_test.go | 64 ++++++++++++++ command/agent/http.go | 2 + vendor/github.com/hashicorp/nomad/api/acl.go | 50 +++++++++++ website/content/api-docs/acl-tokens.mdx | 91 ++++++++++++++++++++ 7 files changed, 329 insertions(+) diff --git a/api/acl.go b/api/acl.go index 02086b155..0652e409c 100644 --- a/api/acl.go +++ b/api/acl.go @@ -154,6 +154,36 @@ func (a *ACLTokens) Self(q *QueryOptions) (*ACLToken, *QueryMeta, error) { return &resp, wm, nil } +// UpsertOneTimeToken is used to create a one-time token +func (a *ACLTokens) UpsertOneTimeToken(q *WriteOptions) (*OneTimeToken, *WriteMeta, error) { + var resp *OneTimeTokenUpsertResponse + wm, err := a.client.write("/v1/acl/token/onetime", nil, &resp, q) + if err != nil { + return nil, nil, err + } + if resp == nil { + return nil, nil, fmt.Errorf("no one-time token returned") + } + return resp.OneTimeToken, wm, nil +} + +// ExchangeOneTimeToken is used to create a one-time token +func (a *ACLTokens) ExchangeOneTimeToken(secret string, q *WriteOptions) (*ACLToken, *WriteMeta, error) { + if secret == "" { + return nil, nil, fmt.Errorf("missing secret ID") + } + req := &OneTimeTokenExchangeRequest{OneTimeSecretID: secret} + var resp *OneTimeTokenExchangeResponse + wm, err := a.client.write("/v1/acl/token/onetime/exchange", req, &resp, q) + if err != nil { + return nil, nil, err + } + if resp == nil { + return nil, nil, fmt.Errorf("no ACL token returned") + } + return resp.Token, wm, nil +} + // ACLPolicyListStub is used to for listing ACL policies type ACLPolicyListStub struct { Name string @@ -194,3 +224,23 @@ type ACLTokenListStub struct { CreateIndex uint64 ModifyIndex uint64 } + +type OneTimeToken struct { + OneTimeSecretID string + AccessorID string + ExpiresAt time.Time + CreateIndex uint64 + ModifyIndex uint64 +} + +type OneTimeTokenUpsertResponse struct { + OneTimeToken *OneTimeToken +} + +type OneTimeTokenExchangeRequest struct { + OneTimeSecretID string +} + +type OneTimeTokenExchangeResponse struct { + Token *ACLToken +} diff --git a/api/acl_test.go b/api/acl_test.go index ddbdc08ab..2a460aa8e 100644 --- a/api/acl_test.go +++ b/api/acl_test.go @@ -235,3 +235,36 @@ func TestACLTokens_Delete(t *testing.T) { assert.Nil(t, err) assertWriteMeta(t, wm) } + +func TestACL_OneTimeToken(t *testing.T) { + t.Parallel() + c, s, _ := makeACLClient(t, nil, nil) + defer s.Stop() + at := c.ACLTokens() + + token := &ACLToken{ + Name: "foo", + Type: "client", + Policies: []string{"foo1"}, + } + + // Create the ACL token + out, wm, err := at.Create(token, nil) + assert.Nil(t, err) + assertWriteMeta(t, wm) + assert.NotNil(t, out) + + // Get a one-time token + c.SetSecretID(out.SecretID) + out2, wm, err := at.UpsertOneTimeToken(nil) + assert.Nil(t, err) + assertWriteMeta(t, wm) + assert.NotNil(t, out2) + + // Exchange the one-time token + out3, wm, err := at.ExchangeOneTimeToken(out2.OneTimeSecretID, nil) + assert.Nil(t, err) + assertWriteMeta(t, wm) + assert.NotNil(t, out3) + assert.Equal(t, out3.AccessorID, out.AccessorID) +} diff --git a/command/agent/acl_endpoint.go b/command/agent/acl_endpoint.go index 40b18048f..c89e6fe2a 100644 --- a/command/agent/acl_endpoint.go +++ b/command/agent/acl_endpoint.go @@ -277,3 +277,42 @@ func (s *HTTPServer) aclTokenDelete(resp http.ResponseWriter, req *http.Request, setIndex(resp, out.Index) return nil, nil } + +func (s *HTTPServer) UpsertOneTimeToken(resp http.ResponseWriter, req *http.Request) (interface{}, error) { + // Ensure this is a PUT or POST + if !(req.Method == "PUT" || req.Method == "POST") { + return nil, CodedError(405, ErrInvalidMethod) + } + + // the request body is empty but we need to parse to get the auth token + args := structs.OneTimeTokenUpsertRequest{} + s.parseWriteRequest(req, &args.WriteRequest) + + var out structs.OneTimeTokenUpsertResponse + if err := s.agent.RPC("ACL.UpsertOneTimeToken", &args, &out); err != nil { + return nil, err + } + setIndex(resp, out.Index) + return out, nil +} + +func (s *HTTPServer) ExchangeOneTimeToken(resp http.ResponseWriter, req *http.Request) (interface{}, error) { + // Ensure this is a PUT or POST + if !(req.Method == "PUT" || req.Method == "POST") { + return nil, CodedError(405, ErrInvalidMethod) + } + + var args structs.OneTimeTokenExchangeRequest + if err := decodeBody(req, &args); err != nil { + return nil, CodedError(500, err.Error()) + } + + s.parseWriteRequest(req, &args.WriteRequest) + + var out structs.OneTimeTokenExchangeResponse + if err := s.agent.RPC("ACL.ExchangeOneTimeToken", &args, &out); err != nil { + return nil, err + } + setIndex(resp, out.Index) + return out, nil +} diff --git a/command/agent/acl_endpoint_test.go b/command/agent/acl_endpoint_test.go index 3da95c438..65cbe8848 100644 --- a/command/agent/acl_endpoint_test.go +++ b/command/agent/acl_endpoint_test.go @@ -8,6 +8,7 @@ import ( "github.com/hashicorp/nomad/nomad/mock" "github.com/hashicorp/nomad/nomad/structs" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestHTTP_ACLPolicyList(t *testing.T) { @@ -448,3 +449,66 @@ func TestHTTP_ACLTokenDelete(t *testing.T) { assert.Nil(t, out) }) } + +func TestHTTP_OneTimeToken(t *testing.T) { + t.Parallel() + httpACLTest(t, nil, func(s *TestAgent) { + + // Setup the ACL token + + p1 := mock.ACLToken() + p1.AccessorID = "" + args := structs.ACLTokenUpsertRequest{ + Tokens: []*structs.ACLToken{p1}, + WriteRequest: structs.WriteRequest{ + Region: "global", + AuthToken: s.RootToken.SecretID, + }, + } + var resp structs.ACLTokenUpsertResponse + err := s.Agent.RPC("ACL.UpsertTokens", &args, &resp) + require.NoError(t, err) + aclID := resp.Tokens[0].AccessorID + aclSecret := resp.Tokens[0].SecretID + + // Make a HTTP request to get a one-time token + + req, err := http.NewRequest("POST", "/v1/acl/token/onetime", nil) + require.NoError(t, err) + req.Header.Set("X-Nomad-Token", aclSecret) + respW := httptest.NewRecorder() + + obj, err := s.Server.UpsertOneTimeToken(respW, req) + require.NoError(t, err) + require.NotNil(t, obj) + + ott := obj.(structs.OneTimeTokenUpsertResponse) + require.Equal(t, aclID, ott.OneTimeToken.AccessorID) + require.NotEqual(t, "", ott.OneTimeToken.OneTimeSecretID) + + // Make a HTTP request to exchange that token + + buf := encodeReq(structs.OneTimeTokenExchangeRequest{ + OneTimeSecretID: ott.OneTimeToken.OneTimeSecretID}) + req, err = http.NewRequest("POST", "/v1/acl/token/onetime/exchange", buf) + respW = httptest.NewRecorder() + + obj, err = s.Server.ExchangeOneTimeToken(respW, req) + require.NoError(t, err) + require.NotNil(t, obj) + + token := obj.(structs.OneTimeTokenExchangeResponse) + require.Equal(t, aclID, token.Token.AccessorID) + require.Equal(t, aclSecret, token.Token.SecretID) + + // Making the same request a second time should return an error + + buf = encodeReq(structs.OneTimeTokenExchangeRequest{ + OneTimeSecretID: ott.OneTimeToken.OneTimeSecretID}) + req, err = http.NewRequest("POST", "/v1/acl/token/onetime/exchange", buf) + respW = httptest.NewRecorder() + + obj, err = s.Server.ExchangeOneTimeToken(respW, req) + require.EqualError(t, err, structs.ErrPermissionDenied.Error()) + }) +} diff --git a/command/agent/http.go b/command/agent/http.go index c7568fd6e..8853a0e81 100644 --- a/command/agent/http.go +++ b/command/agent/http.go @@ -272,6 +272,8 @@ func (s *HTTPServer) registerHandlers(enableDebug bool) { s.mux.HandleFunc("/v1/acl/policies", s.wrap(s.ACLPoliciesRequest)) s.mux.HandleFunc("/v1/acl/policy/", s.wrap(s.ACLPolicySpecificRequest)) + s.mux.HandleFunc("/v1/acl/token/onetime", s.wrap(s.UpsertOneTimeToken)) + s.mux.HandleFunc("/v1/acl/token/onetime/exchange", s.wrap(s.ExchangeOneTimeToken)) s.mux.HandleFunc("/v1/acl/bootstrap", s.wrap(s.ACLTokenBootstrap)) s.mux.HandleFunc("/v1/acl/tokens", s.wrap(s.ACLTokensRequest)) s.mux.HandleFunc("/v1/acl/token", s.wrap(s.ACLTokenSpecificRequest)) diff --git a/vendor/github.com/hashicorp/nomad/api/acl.go b/vendor/github.com/hashicorp/nomad/api/acl.go index 02086b155..0652e409c 100644 --- a/vendor/github.com/hashicorp/nomad/api/acl.go +++ b/vendor/github.com/hashicorp/nomad/api/acl.go @@ -154,6 +154,36 @@ func (a *ACLTokens) Self(q *QueryOptions) (*ACLToken, *QueryMeta, error) { return &resp, wm, nil } +// UpsertOneTimeToken is used to create a one-time token +func (a *ACLTokens) UpsertOneTimeToken(q *WriteOptions) (*OneTimeToken, *WriteMeta, error) { + var resp *OneTimeTokenUpsertResponse + wm, err := a.client.write("/v1/acl/token/onetime", nil, &resp, q) + if err != nil { + return nil, nil, err + } + if resp == nil { + return nil, nil, fmt.Errorf("no one-time token returned") + } + return resp.OneTimeToken, wm, nil +} + +// ExchangeOneTimeToken is used to create a one-time token +func (a *ACLTokens) ExchangeOneTimeToken(secret string, q *WriteOptions) (*ACLToken, *WriteMeta, error) { + if secret == "" { + return nil, nil, fmt.Errorf("missing secret ID") + } + req := &OneTimeTokenExchangeRequest{OneTimeSecretID: secret} + var resp *OneTimeTokenExchangeResponse + wm, err := a.client.write("/v1/acl/token/onetime/exchange", req, &resp, q) + if err != nil { + return nil, nil, err + } + if resp == nil { + return nil, nil, fmt.Errorf("no ACL token returned") + } + return resp.Token, wm, nil +} + // ACLPolicyListStub is used to for listing ACL policies type ACLPolicyListStub struct { Name string @@ -194,3 +224,23 @@ type ACLTokenListStub struct { CreateIndex uint64 ModifyIndex uint64 } + +type OneTimeToken struct { + OneTimeSecretID string + AccessorID string + ExpiresAt time.Time + CreateIndex uint64 + ModifyIndex uint64 +} + +type OneTimeTokenUpsertResponse struct { + OneTimeToken *OneTimeToken +} + +type OneTimeTokenExchangeRequest struct { + OneTimeSecretID string +} + +type OneTimeTokenExchangeResponse struct { + Token *ACLToken +} diff --git a/website/content/api-docs/acl-tokens.mdx b/website/content/api-docs/acl-tokens.mdx index 28efc8da9..2632f9bfe 100644 --- a/website/content/api-docs/acl-tokens.mdx +++ b/website/content/api-docs/acl-tokens.mdx @@ -342,3 +342,94 @@ $ curl \ --request DELETE \ https://localhost:4646/v1/acl/token/aa534e09-6a07-0a45-2295-a7f77063d429 ``` + +## Upsert One-Time Token + +This endpoint creates a one-time token for the ACL token provided in the +`X-Nomad-Token` header. Returns 403 if the token header is not set. + +| Method | Path | Produces | +| -------- | ------------------------- | -------------- | +| `POST` | `/acl/token/onetime` | `application/json` | + +The table below shows this endpoint's support for +[blocking queries](/api-docs#blocking-queries) and +[required ACLs](/api-docs#acls). + +| Blocking Queries | ACL Required | +| ---------------- | ------------ | +| `NO` | `any` | + + +### Sample Request + +```shell-session +$ curl \ + --request POST \ + -H "X-Nomad-Token: aa534e09-6a07-0a45-2295-a7f77063d429" \ + https://localhost:4646/v1/acl/token/onetime +``` + +### Sample Response + +```json +{ + "Index": 15, + "OneTimeToken": { + "AccessorID": "b780e702-98ce-521f-2e5f-c6b87de05b24", + "CreateIndex": 7, + "ExpiresAt": "2017-08-23T22:47:14.695408057Z", + "ModifyIndex": 7, + "OneTimeSecretID": "3f4a0fcd-7c42-773c-25db-2d31ba0c05fe" + } +} +``` + +## Exchange One-Time Token + +This endpoint exchanges a one-time token for the original ACL token used to +create it. + +| Method | Path | Produces | +| -------- | ------------------------- | -------------- | +| `POST` | `/acl/token/onetime/exchange` | `application/json` | + +The table below shows this endpoint's support for +[blocking queries](/api-docs#blocking-queries) and +[required ACLs](/api-docs#acls). + +| Blocking Queries | ACL Required | +| ---------------- | ------------ | +| `NO` | `any` | + + +### Sample Request + +```shell-session +$ curl \ + --request POST \ + -d '{ "OneTimeSecretID": "aa534e09-6a07-0a45-2295-a7f77063d429" } \ + https://localhost:4646/v1/acl/token/onetime/exchange +``` + +### Sample Response + +```json +{ + "Index": 17, + "Token": { + "AccessorID": "b780e702-98ce-521f-2e5f-c6b87de05b24", + "CreateIndex": 7, + "CreateTime": "2017-08-23T22:47:14.695408057Z", + "Global": true, + "Hash": "UhZESkSFGFfX7eBgq5Uwph30OctbUbpe8+dlH2i4whA=", + "ModifyIndex": 7, + "Name": "Developer token", + "Policies": [ + "developer" + ], + "SecretID": "3f4a0fcd-7c42-773c-25db-2d31ba0c05fe", + "Type": "client" + } +} +```