Skip to content

wi: new endpoint for listing workload attached ACL policies #25588

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 23 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .changelog/25588.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
```release-note:improvement
wi: new API endpoint for listing workload-attached ACL policies
```
10 changes: 10 additions & 0 deletions api/acl.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,16 @@ func (a *ACLPolicies) Info(policyName string, q *QueryOptions) (*ACLPolicy, *Que
return &resp, wm, nil
}

// Self is used to query policies attached to a workload identity
func (a *ACLPolicies) Self(q *QueryOptions) (map[string]*ACLPolicy, *QueryMeta, error) {
var resp map[string]*ACLPolicy
wm, err := a.client.query("/v1/acl/policy/self", &resp, q)
if err != nil {
return nil, nil, err
}
return resp, wm, nil
}

// ACLTokens is used to query the ACL token endpoints.
type ACLTokens struct {
client *Client
Expand Down
2 changes: 1 addition & 1 deletion api/util_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import (
func assertQueryMeta(t *testing.T, qm *QueryMeta) {
t.Helper()

must.NotEq(t, 0, qm.LastIndex, must.Sprint("bad index"))
must.NotEq(t, 0, qm.LastIndex, must.Sprint("expected QueryMeta.LastIndex to be != 0"))
must.True(t, qm.KnownLeader, must.Sprint("expected a known leader but gone none"))
}

Expand Down
128 changes: 128 additions & 0 deletions command/acl_policy_self.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1

package command

import (
"fmt"
"strings"

"github.com/posener/complete"
)

type ACLPolicySelfCommand struct {
Meta

json bool
tmpl string
}

func (c *ACLPolicySelfCommand) Help() string {
helpText := `
Usage: nomad acl policy self

Self is used to fetch information about the policy assigned to the current workload identity.

General Options:

` + generalOptionsUsage(usageOptsDefault|usageOptsNoNamespace) + `

ACL List Options:

-json
Output the ACL policies in a JSON format.

-t
Format and display the ACL policies using a Go template.
`
return strings.TrimSpace(helpText)
}

func (c *ACLPolicySelfCommand) AutocompleteFlags() complete.Flags {
return mergeAutocompleteFlags(c.Meta.AutocompleteFlags(FlagSetClient),
complete.Flags{
"-json": complete.PredictNothing,
"-t": complete.PredictAnything,
})
}

func (c *ACLPolicySelfCommand) AutocompleteArgs() complete.Predictor {
return complete.PredictNothing
}

func (c *ACLPolicySelfCommand) Synopsis() string {
return "Lookup self ACL policy assigned to the workload identity"
}

func (c *ACLPolicySelfCommand) Name() string { return "acl policy self" }

func (c *ACLPolicySelfCommand) Run(args []string) int {
flags := c.Meta.FlagSet(c.Name(), FlagSetClient)
flags.Usage = func() { c.Ui.Output(c.Help()) }
flags.BoolVar(&c.json, "json", false, "")
flags.StringVar(&c.tmpl, "t", "", "")
if err := flags.Parse(args); err != nil {
return 1
}

// Check that we have no arguments
args = flags.Args()
if l := len(args); l != 0 {
c.Ui.Error("This command takes no arguments")
c.Ui.Error(commandErrorText(c))
return 1
}

// Get the HTTP client
client, err := c.Meta.Client()
if err != nil {
c.Ui.Error(fmt.Sprintf("Error initializing client: %s", err))
return 1
}

policies, _, err := client.ACLPolicies().Self(nil)
if err != nil {
c.Ui.Error(fmt.Sprintf("Error fetching WI policies: %s", err))
return 1
}

if len(policies) == 0 {
c.Ui.Output("No policies found for this identity.")
} else {
if c.json || len(c.tmpl) > 0 {
out, err := Format(c.json, c.tmpl, policies)
if err != nil {
c.Ui.Error(err.Error())
return 1
}

c.Ui.Output(out)
return 0
}

output := make([]string, 0, len(policies)+1)
output = append(output, "Name|Job ID|Group Name|Task Name")
for _, p := range policies {
var outputString string
if p.JobACL == nil {
outputString = fmt.Sprintf("%s|%s|%s|%s", p.Name, "<unavailable>", "<unavailable>", "<unavailable>")
} else {
outputString = fmt.Sprintf(
"%s|%s|%s|%s",
p.Name, formatJobACL(p.JobACL.JobID), formatJobACL(p.JobACL.Group), formatJobACL(p.JobACL.Task),
)
}
output = append(output, outputString)
}

c.Ui.Output(formatList(output))
}
return 0
}

func formatJobACL(jobACL string) string {
if jobACL == "" {
return "<not specified>"
}
return jobACL
}
79 changes: 79 additions & 0 deletions command/acl_policy_self_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1

package command

import (
"testing"

"github.com/hashicorp/cli"
"github.com/hashicorp/nomad/command/agent"
"github.com/hashicorp/nomad/nomad/mock"
"github.com/hashicorp/nomad/nomad/structs"
"github.com/hashicorp/nomad/testutil"
"github.com/shoenig/test/must"
)

func TestACLPolicySelfCommand_ViaEnvVar(t *testing.T) {
config := func(c *agent.Config) {
c.ACL.Enabled = true
}

srv, _, url := testServer(t, true, config)
defer srv.Shutdown()

state := srv.Agent.Server().State()

// Bootstrap an initial ACL token
token := srv.RootToken
must.NotNil(t, token)

// Create a minimal job
job := mock.MinJob()

// Add a job policy
polArgs := structs.ACLPolicyUpsertRequest{
Policies: []*structs.ACLPolicy{
{
Name: "nw",
Description: "test job can write to nodes",
Rules: `node { policy = "write" }`,
JobACL: &structs.JobACL{
Namespace: job.Namespace,
JobID: job.ID,
},
},
},
WriteRequest: structs.WriteRequest{
Region: job.Region,
AuthToken: token.SecretID,
Namespace: job.Namespace,
},
}
polReply := structs.GenericResponse{}
must.NoError(t, srv.RPC("ACL.UpsertPolicies", &polArgs, &polReply))
must.NonZero(t, polReply.WriteMeta.Index)

ui := cli.NewMockUi()
cmd := &ACLPolicySelfCommand{Meta: Meta{Ui: ui, flagAddress: url}}

allocs := testutil.WaitForRunningWithToken(t, srv.RPC, job, token.SecretID)
must.Len(t, 1, allocs)

alloc, err := state.AllocByID(nil, allocs[0].ID)
must.NoError(t, err)
must.MapContainsKey(t, alloc.SignedIdentities, "t")
wid := alloc.SignedIdentities["t"]

// Fetch info on policies with a JWT
t.Setenv("NOMAD_TOKEN", wid)
code := cmd.Run([]string{"-address=" + url})
must.Zero(t, code)

// Check the output
out := ui.OutputWriter.String()
must.StrContains(t, out, polArgs.Policies[0].Name)

// make sure we put the job ACLs in there, too
must.StrContains(t, out, polArgs.Policies[0].JobACL.JobID)
}
12 changes: 11 additions & 1 deletion command/acl_token_self.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,17 @@ func (c *ACLTokenSelfCommand) Run(args []string) int {
// Get the specified token information
token, _, err := client.ACLTokens().Self(nil)
if err != nil {
c.Ui.Error(fmt.Sprintf("Error fetching self token: %s", err))
if !strings.Contains(err.Error(), "Unexpected response code: 404") {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this bool check is reversed, because this is what happens when I use a non-existent token in current main:

$ NOMAD_TOKEN=10000000-0000-0000-0000-000000000000 nomad acl token self
Error fetching self token: Unexpected response code: 404 (ACL token not found)
Suggested change
if !strings.Contains(err.Error(), "Unexpected response code: 404") {
if strings.Contains(err.Error(), "Unexpected response code: 404") {

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 I don't follow, Daniel.

On main you will of course get 404 if you try to query token self with WI. What you're showing above is strange to me, prefixing nomad acl token self with a bogus token should give you 403 instead.

The conditional here is correct, though. For any error other than 404, we return error. In case we get 404, we further check job-attached policies, and only if we cannot find any, we say: no acl token and no policies attached.

Does that make sense?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what exactly I was thinking back then is lost to the mists of time, alas.

but, while I'm here comparing the two error messages from a bad, but valid (uuid), ACL token:

  • nomad release 1.10 is clearly about one thing:

    Error fetching self token: Unexpected response code: 404 (ACL token not found)

  • this pr:

    No ACL tokens or ACL policies attached to a workload identity found.

the new one seems to me like acl token self is only for workload ID, because I don't parse "or ACL policies attached to a workload identity" as a separate item? I get lost grammatically somewhere along the way before hitting "found" and I parse it as "No ... workload identity found."

this might be resolved for me with a single character:

No ACL tokens nor ACL policies attached to a workload identity found.

for bonus clarity, if a bit choppy in rhythm:

No ACL tokens, nor ACL policies attached to a workload identity, found.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

c.Ui.Error(fmt.Sprintf("Error fetching self token: %s", err))
return 1
}
// Check if perhaps there's a WI
policies, _, err := client.ACLPolicies().Self(nil)
if err == nil && len(policies) > 0 {
c.Ui.Info("No ACL token found but there are ACL policies attached to this workload identity. You can query them with acl policy self command.")
return 0
}
c.Ui.Error("No ACL tokens, nor ACL policies attached to a workload identity found.")
return 1
}

Expand Down
57 changes: 48 additions & 9 deletions command/agent/acl_endpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ func (s *HTTPServer) ACLPoliciesRequest(resp http.ResponseWriter, req *http.Requ
}

func (s *HTTPServer) ACLPolicySpecificRequest(resp http.ResponseWriter, req *http.Request) (interface{}, error) {
// handle the special case for "self" call
if req.URL.Path == "/v1/acl/policy/self" {
return s.aclSelfPolicy(resp, req)
}

name := strings.TrimPrefix(req.URL.Path, "/v1/acl/policy/")
if len(name) == 0 {
return nil, CodedError(400, "Missing Policy Name")
Expand All @@ -46,7 +51,7 @@ func (s *HTTPServer) ACLPolicySpecificRequest(resp http.ResponseWriter, req *htt
case http.MethodDelete:
return s.aclPolicyDelete(resp, req, name)
default:
return nil, CodedError(405, ErrInvalidMethod)
return nil, CodedError(http.StatusMethodNotAllowed, ErrInvalidMethod)
}
}

Expand Down Expand Up @@ -180,6 +185,42 @@ func (s *HTTPServer) ACLTokenSpecificRequest(resp http.ResponseWriter, req *http
return s.aclTokenCrud(resp, req, accessor)
}

func (s *HTTPServer) aclSelfPolicy(resp http.ResponseWriter, req *http.Request) (interface{}, error) {
if req.Method != http.MethodGet {
return nil, CodedError(http.StatusMethodNotAllowed, ErrInvalidMethod)
}

wiPolicyReq := structs.GenericRequest{}
if s.parse(resp, req, &wiPolicyReq.Region, &wiPolicyReq.QueryOptions) {
return nil, nil
}

// is it a JWT or a Nomad ACL token?
if len(wiPolicyReq.AuthToken) > 36 {

// Resolve policies for workload identities
wiPolicyReply := structs.ACLPolicySetResponse{}
if err := s.agent.RPC("ACL.GetClaimPolicies", &wiPolicyReq, &wiPolicyReply); err != nil {
return nil, err
}
setMeta(resp, &wiPolicyReply.QueryMeta)

if wiPolicyReply.Policies == nil {
wiPolicyReply.Policies = make(map[string]*structs.ACLPolicy, 0)
}
return wiPolicyReply.Policies, nil
}

// Resolve any authenticated policies
policiesListReq := &structs.ACLPolicyListRequest{QueryOptions: wiPolicyReq.QueryOptions}
policiesListReply := structs.ACLPolicyListResponse{}
if err := s.agent.RPC("ACL.ListPolicies", policiesListReq, &policiesListReply); err != nil {
return nil, err
}

return policiesListReply.Policies, nil
}

func (s *HTTPServer) aclTokenCrud(resp http.ResponseWriter, req *http.Request,
tokenAccessor string) (interface{}, error) {
if tokenAccessor == "" {
Expand Down Expand Up @@ -219,27 +260,25 @@ func (s *HTTPServer) aclTokenQuery(resp http.ResponseWriter, req *http.Request,
return out.Token, nil
}

func (s *HTTPServer) aclTokenSelf(resp http.ResponseWriter, req *http.Request) (interface{}, error) {
func (s *HTTPServer) aclTokenSelf(resp http.ResponseWriter, req *http.Request) (any, error) {
if req.Method != http.MethodGet {
return nil, CodedError(405, ErrInvalidMethod)
}
args := structs.ResolveACLTokenRequest{}
args := structs.GenericRequest{}
if s.parse(resp, req, &args.Region, &args.QueryOptions) {
return nil, nil
}

args.SecretID = args.AuthToken

var out structs.ResolveACLTokenResponse
if err := s.agent.RPC("ACL.ResolveToken", &args, &out); err != nil {
var out structs.ACLWhoAmIResponse
if err := s.agent.RPC("ACL.WhoAmI", &args, &out); err != nil {
return nil, err
}

setMeta(resp, &out.QueryMeta)
if out.Token == nil {
if out.Identity == nil || out.Identity.ACLToken == nil {
return nil, CodedError(404, "ACL token not found")
}
return out.Token, nil
return out.Identity.ACLToken, nil
}

func (s *HTTPServer) aclTokenUpdate(resp http.ResponseWriter, req *http.Request,
Expand Down
Loading