From daca59cf33bc9d45004524bc54ebb563ddf366ed Mon Sep 17 00:00:00 2001 From: Hinne Stolzenberg Date: Fri, 18 Sep 2026 08:18:46 +0200 Subject: [PATCH 01/11] feat: select the Jira REST API version on the api passthrough The passthrough was bound to /rest/api/3. Add --api-version so v2 is reachable: both versions expose the same resources and differ in how they carry rich text, so reading through v2 returns a description as wiki markup instead of ADF. The version is interpolated into the URL, which the path-level guard never sees, so it is validated against an allowlist inside the API layer rather than only at the flag. --- internal/api/client.go | 36 ++++++++++++++++++++++++++++++++- internal/api/client_test.go | 38 +++++++++++++++++++++++++++++++++++ internal/api/jira.go | 27 +++++++++++++++++-------- internal/api/jira_test.go | 9 +++++++++ internal/cmd/jira/api.go | 25 ++++++++++++++++------- internal/cmd/jira/api_test.go | 26 ++++++++++++++++++++++++ 6 files changed, 145 insertions(+), 16 deletions(-) diff --git a/internal/api/client.go b/internal/api/client.go index b264311..ca87f5c 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -30,6 +30,7 @@ import ( "net/url" "os" "path/filepath" + "slices" "strings" "time" @@ -45,12 +46,23 @@ const ( // DefaultTimeout is the default HTTP client timeout for API requests. DefaultTimeout = 30 * time.Second + // DefaultJiraAPIVersion is the Jira platform REST API version atl addresses + // unless a caller selects another one. v3 is the version that speaks ADF, + // which every atl command that writes issue content depends on. + DefaultJiraAPIVersion = "3" + // Retry configuration for transient failures maxRetries = 3 initialBackoff = 500 * time.Millisecond maxBackoff = 10 * time.Second ) +// SupportedJiraAPIVersions lists the Jira platform REST API versions atl will +// address. v2 and v3 expose the same resources and differ in how they represent +// rich text: v2 takes and returns wiki markup, v3 takes and returns ADF. Reading +// through v2 is the way to get a plain-text description without an ADF walk. +var SupportedJiraAPIVersions = []string{"2", "3"} + // isDebug returns true if debug logging is enabled via ATL_DEBUG=1 environment variable. func isDebug() bool { return os.Getenv("ATL_DEBUG") == "1" @@ -180,7 +192,29 @@ func (c *Client) JiraGatewayBaseURL() string { // JiraBaseURL returns the base URL for Jira API requests. func (c *Client) JiraBaseURL() string { - return c.JiraGatewayBaseURL() + "/rest/api/3" + return c.JiraGatewayBaseURL() + "/rest/api/" + DefaultJiraAPIVersion +} + +// JiraBaseURLVersion returns the base URL for a specific Jira platform REST API +// version. Only the versions in SupportedJiraAPIVersions are accepted: the +// version is interpolated into the URL, so an unchecked value ("3/../../..") +// would leave the Jira REST namespace entirely, which no path-level guard on the +// caller's side can catch. +func (c *Client) JiraBaseURLVersion(version string) (string, error) { + if err := ValidateJiraAPIVersion(version); err != nil { + return "", err + } + return c.JiraGatewayBaseURL() + "/rest/api/" + version, nil +} + +// ValidateJiraAPIVersion reports whether version names a Jira platform REST API +// version atl will address. +func ValidateJiraAPIVersion(version string) error { + if slices.Contains(SupportedJiraAPIVersions, version) { + return nil + } + return fmt.Errorf("unsupported Jira API version %q; supported: %s", + version, strings.Join(SupportedJiraAPIVersions, ", ")) } // ConfluenceBaseURL returns the base URL for Confluence API requests. diff --git a/internal/api/client_test.go b/internal/api/client_test.go index b92f680..0511cbe 100644 --- a/internal/api/client_test.go +++ b/internal/api/client_test.go @@ -423,3 +423,41 @@ func TestEnsureValidTokenUsesKeychainCredentials(t *testing.T) { t.Fatalf("token not refreshed, got %q", c.tokens.AccessToken) } } + +// TestJiraBaseURLVersion is the guard on the interpolated version segment: an +// unchecked value would rewrite the URL's namespace, and the passthrough's +// path-level validation never sees it. +func TestJiraBaseURLVersion(t *testing.T) { + client := &Client{cloudID: "cloud-123"} + + for _, version := range SupportedJiraAPIVersions { + got, err := client.JiraBaseURLVersion(version) + if err != nil { + t.Fatalf("JiraBaseURLVersion(%q) error: %v", version, err) + } + want := "https://api.atlassian.com/ex/jira/cloud-123/rest/api/" + version + if got != want { + t.Errorf("JiraBaseURLVersion(%q) = %q, want %q", version, got, want) + } + } + + rejected := []string{"", "1", "4", "3/../../../..", "../agile/1.0", "3 ", "v3", "3;x"} + for _, version := range rejected { + if _, err := client.JiraBaseURLVersion(version); err == nil { + t.Errorf("JiraBaseURLVersion(%q) accepted an unsupported version", version) + } + } +} + +// TestJiraBaseURLMatchesDefaultVersion keeps the unversioned helper and the +// declared default from drifting apart. +func TestJiraBaseURLMatchesDefaultVersion(t *testing.T) { + client := &Client{cloudID: "cloud-123"} + versioned, err := client.JiraBaseURLVersion(DefaultJiraAPIVersion) + if err != nil { + t.Fatal(err) + } + if got := client.JiraBaseURL(); got != versioned { + t.Errorf("JiraBaseURL() = %q, want %q", got, versioned) + } +} diff --git a/internal/api/jira.go b/internal/api/jira.go index 60ab622..6d587c3 100644 --- a/internal/api/jira.go +++ b/internal/api/jira.go @@ -730,11 +730,11 @@ func (s *JiraService) GetProjectSecurityLevels(ctx context.Context, projectKey s return result.Levels, nil } -// validateRawPath confines a passthrough path to the REST v3 base. The path +// validateRawPath confines a passthrough path to the selected REST base. The path // component is required to be plain — no scheme/host (which would point at // another host), and no "%", ";", or "\" (percent-encoding, Tomcat path // parameters, and backslashes each smuggle a decoded ".." past a segment scan -// and out of /rest/api/3, and double-encoding defeats any decode-then-scan). +// and out of the REST base, and double-encoding defeats any decode-then-scan). // An allowlist ("plain segments only") closes that whole bypass class where a // blocklist would chase each encoding. Encoded values belong in the query // string, which is left untouched. This is a namespace guardrail, not an authz @@ -766,16 +766,27 @@ func validateRawPath(apiPath string) error { return nil } -// RawGet performs a read-only GET against a path relative to the Jira REST base -// (e.g. "issue/NX-1/editmeta") and returns the raw JSON body. It is the escape -// hatch for endpoints atl does not model as first-class commands; the path is -// confined to the REST base via validateRawPath so it cannot reach another host -// or climb above /rest/api/3. +// RawGet performs a read-only GET against a path relative to the default Jira +// REST base and returns the raw JSON body. func (s *JiraService) RawGet(ctx context.Context, apiPath string) (json.RawMessage, error) { + return s.RawGetVersion(ctx, DefaultJiraAPIVersion, apiPath) +} + +// RawGetVersion performs a read-only GET against a path relative to the Jira +// platform REST base of the given API version (e.g. "issue/NX-1/editmeta") and +// returns the raw JSON body. It is the escape hatch for endpoints atl does not +// model as first-class commands. Both halves of the URL are constrained: the +// version against an allowlist, and the path via validateRawPath, so the request +// cannot reach another host or climb out of the selected REST base. +func (s *JiraService) RawGetVersion(ctx context.Context, apiVersion, apiPath string) (json.RawMessage, error) { if err := validateRawPath(apiPath); err != nil { return nil, err } - path := fmt.Sprintf("%s/%s", s.client.JiraBaseURL(), strings.TrimPrefix(apiPath, "/")) + base, err := s.client.JiraBaseURLVersion(apiVersion) + if err != nil { + return nil, err + } + path := fmt.Sprintf("%s/%s", base, strings.TrimPrefix(apiPath, "/")) var result json.RawMessage if err := s.client.Get(ctx, path, &result); err != nil { diff --git a/internal/api/jira_test.go b/internal/api/jira_test.go index aeb150c..0691dce 100644 --- a/internal/api/jira_test.go +++ b/internal/api/jira_test.go @@ -755,3 +755,12 @@ func TestRawGet_ValidationBeforeNetwork(t *testing.T) { t.Errorf("error = %q, want a traversal-rejection message", err.Error()) } } + +// TestRawGetVersionRejectsUnsupportedVersion proves the version is validated +// before any request is built, so a bad version cannot reach the network. +func TestRawGetVersionRejectsUnsupportedVersion(t *testing.T) { + service := NewJiraService(&Client{cloudID: "cloud-123"}) + if _, err := service.RawGetVersion(context.Background(), "3/../../..", "issue/NX-1"); err == nil { + t.Fatal("RawGetVersion() accepted a traversing version") + } +} diff --git a/internal/cmd/jira/api.go b/internal/cmd/jira/api.go index 23923a1..ab201dd 100644 --- a/internal/cmd/jira/api.go +++ b/internal/cmd/jira/api.go @@ -16,8 +16,9 @@ import ( // APIOptions holds the options for the api command. type APIOptions struct { - IO *iostreams.IOStreams - Path string + IO *iostreams.IOStreams + Path string + APIVersion string } // NewCmdAPI creates the api passthrough command. @@ -29,22 +30,29 @@ func NewCmdAPI(ios *iostreams.IOStreams) *cobra.Command { cmd := &cobra.Command{ Use: "api [GET] ", Short: "Make a read-only GET request to the Jira REST API", - Long: `Make a read-only GET request against the Jira Cloud REST API (v3) and print -the JSON response. + Long: `Make a read-only GET request against the Jira Cloud platform REST API and +print the JSON response. This is an escape hatch for endpoints atl does not model as first-class commands (editmeta, project metadata, and similar read-only lookups). Only GET is supported — atl deliberately does not expose write passthrough. - is relative to the REST v3 base, with or without a leading slash: + is relative to the REST base, with or without a leading slash: issue/NX-1234/editmeta - /project/NX/securitylevel`, + /project/NX/securitylevel + +--api-version selects the platform API version. Both expose the same resources +and differ in how they carry rich text: v3 uses ADF, v2 uses wiki markup. Read +through v2 when a plain-text description is easier to work with than ADF.`, Example: ` # Inspect an issue's edit metadata (allowed fields and values) atl jira api GET issue/NX-1234/editmeta # List a project's issue security levels atl jira api project/NX/securitylevel + # Read a description as wiki markup instead of ADF + atl --context prod jira api --api-version 2 issue/NX-1234?fields=description + # Pipe into jq atl jira api GET issue/NX-1234/editmeta | jq '.fields | keys'`, Args: cobra.RangeArgs(1, 2), @@ -66,6 +74,9 @@ is supported — atl deliberately does not expose write passthrough. }, } + cmd.Flags().StringVar(&opts.APIVersion, "api-version", api.DefaultJiraAPIVersion, + fmt.Sprintf("Jira platform REST API version (%s)", strings.Join(api.SupportedJiraAPIVersions, ", "))) + return cmd } @@ -78,7 +89,7 @@ func runAPI(opts *APIOptions) error { ctx := context.Background() jira := api.NewJiraService(client) - raw, err := jira.RawGet(ctx, opts.Path) + raw, err := jira.RawGetVersion(ctx, opts.APIVersion, opts.Path) if err != nil { return fmt.Errorf("request failed: %w", err) } diff --git a/internal/cmd/jira/api_test.go b/internal/cmd/jira/api_test.go index d2b7a9b..d34e7c5 100644 --- a/internal/cmd/jira/api_test.go +++ b/internal/cmd/jira/api_test.go @@ -6,6 +6,7 @@ import ( "strings" "testing" + "github.com/enthus-appdev/atl-cli/internal/api" "github.com/enthus-appdev/atl-cli/internal/iostreams" ) @@ -68,3 +69,28 @@ func TestWriteIndentedJSON_NonJSONFallback(t *testing.T) { t.Errorf("non-JSON body not echoed; got: %s", buf.String()) } } + +// TestNewCmdAPI_APIVersionFlag pins the default version and the rejection of an +// unsupported one. The rejection happens before the client is built, so it needs +// no HTTP. +func TestNewCmdAPI_APIVersionFlag(t *testing.T) { + cmd := NewCmdAPI(iostreams.Test()) + flag := cmd.Flags().Lookup("api-version") + if flag == nil { + t.Fatal("api-version flag not registered") + } + if got, want := flag.DefValue, api.DefaultJiraAPIVersion; got != want { + t.Errorf("api-version default = %q, want %q", got, want) + } + + cmd.SetArgs([]string{"--api-version", "9", "issue/NX-1"}) + cmd.SilenceUsage = true + cmd.SilenceErrors = true + err := cmd.Execute() + if err == nil { + t.Fatal("expected an error for an unsupported api version") + } + if !strings.Contains(err.Error(), "unsupported Jira API version") { + t.Errorf("error = %q, want it to mention an unsupported version", err.Error()) + } +} From dca8fb81d195d0dbae8b432828792a87f6e871c5 Mon Sep 17 00:00:00 2001 From: Hinne Stolzenberg Date: Fri, 18 Sep 2026 08:18:58 +0200 Subject: [PATCH 02/11] feat: read Assets object type attributes An Assets object omits every attribute it holds no value for, so reading one object cannot show whether an attribute exists on its type at all. Add 'jira assets attributes ' (alias 'fields'), which reads the type definition instead, and prints the type's name above the table so a wrong id is distinguishable from a type that genuinely lacks the attribute. Assets grants reads per resource kind: the two endpoints need read:cmdb-type:jira and read:cmdb-attribute:jira, which the existing object and schema scopes do not cover. Both are added to the requested scopes and checked before the request, so a token without them fails with the scope named rather than an opaque 401 "scope does not match". --- internal/api/assets.go | 74 ++++++++++++++++++ internal/api/assets_test.go | 82 +++++++++++++++++++- internal/auth/oauth.go | 15 +++- internal/cmd/assets/assets.go | 1 + internal/cmd/assets/attributes.go | 103 +++++++++++++++++++++++++ internal/cmd/assets/attributes_test.go | 44 +++++++++++ 6 files changed, 315 insertions(+), 4 deletions(-) create mode 100644 internal/cmd/assets/attributes.go create mode 100644 internal/cmd/assets/attributes_test.go diff --git a/internal/api/assets.go b/internal/api/assets.go index 8a66b42..8c12f62 100644 --- a/internal/api/assets.go +++ b/internal/api/assets.go @@ -198,3 +198,77 @@ func (c *AssetsClient) AQLCount(ctx context.Context, ql string) (int, error) { start += len(vals) } } + +// AssetObjectType is one object type (the "class" an Assets object belongs to). +type AssetObjectType struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + ObjectSchemaID string `json:"objectSchemaId,omitempty"` + ParentObjectTypeID string `json:"parentObjectTypeId,omitempty"` + ObjectCount int `json:"objectCount,omitempty"` + Inherited bool `json:"inherited,omitempty"` +} + +// AssetObjectTypeAttribute is one attribute definition on an object type: the +// field itself, independent of any object's value for it. An object omits every +// attribute it holds no value for, so reading one object can never prove an +// attribute absent from its type; this is what does. +type AssetObjectTypeAttribute struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Label bool `json:"label,omitempty"` + Type int `json:"type"` + DefaultType struct { + ID int `json:"id"` + Name string `json:"name"` + } `json:"defaultType"` + System bool `json:"system,omitempty"` + Editable bool `json:"editable,omitempty"` + Hidden bool `json:"hidden,omitempty"` + UniqueAttribute bool `json:"uniqueAttribute,omitempty"` + MinimumCardinality int `json:"minimumCardinality"` + MaximumCardinality int `json:"maximumCardinality"` + Position int `json:"position"` +} + +// Required reports whether the attribute must carry at least one value. +func (a AssetObjectTypeAttribute) Required() bool { + return a.MinimumCardinality > 0 +} + +// ObjectType loads one object type by id. Its name is what tells a caller the id +// addresses the type they meant: a wrong id and a type without the attribute +// being looked for are otherwise indistinguishable. +func (c *AssetsClient) ObjectType(ctx context.Context, objectTypeID string) (*AssetObjectType, error) { + if err := c.requireScopes(auth.AssetsTypeReadScope); err != nil { + return nil, err + } + base, err := c.v1(ctx) + if err != nil { + return nil, err + } + var objectType AssetObjectType + if err := c.do(ctx, http.MethodGet, base+"/objecttype/"+url.PathEscape(objectTypeID), nil, &objectType); err != nil { + return nil, err + } + return &objectType, nil +} + +// ObjectTypeAttributes returns every attribute defined on an object type, +// including the ones inherited from a parent type. +func (c *AssetsClient) ObjectTypeAttributes(ctx context.Context, objectTypeID string) ([]AssetObjectTypeAttribute, error) { + if err := c.requireScopes(auth.AssetsAttributeReadScope); err != nil { + return nil, err + } + base, err := c.v1(ctx) + if err != nil { + return nil, err + } + var attributes []AssetObjectTypeAttribute + if err := c.do(ctx, http.MethodGet, base+"/objecttype/"+url.PathEscape(objectTypeID)+"/attributes", nil, &attributes); err != nil { + return nil, err + } + return attributes, nil +} diff --git a/internal/api/assets_test.go b/internal/api/assets_test.go index 4d7a578..0ba64cc 100644 --- a/internal/api/assets_test.go +++ b/internal/api/assets_test.go @@ -20,7 +20,12 @@ func newTestAssetsClient(server *httptest.Server, workspaceID string) *AssetsCli tokens: &auth.TokenSet{ AccessToken: "test-token", ExpiresAt: time.Now().Add(time.Hour), - Scopes: []string{auth.AssetsObjectReadScope, auth.AssetsSchemaReadScope}, + Scopes: []string{ + auth.AssetsObjectReadScope, + auth.AssetsSchemaReadScope, + auth.AssetsTypeReadScope, + auth.AssetsAttributeReadScope, + }, }, } return &AssetsClient{ @@ -185,3 +190,78 @@ func TestAssetsObjectRejectsWorkspaceMismatch(t *testing.T) { t.Fatal("Object() succeeded for a different workspace") } } + +func TestAssetsObjectTypeAttributes(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + requireBearer(t, request) + if request.URL.Path != "/ex/jira/cloud-123/jsm/assets/workspace/workspace-456/v1/objecttype/9/attributes" { + t.Fatalf("path = %q", request.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[ + {"id":"550","name":"Import-Key","label":false,"type":0, + "defaultType":{"id":0,"name":"Text"},"system":true,"editable":false, + "minimumCardinality":1,"maximumCardinality":1,"position":0}, + {"id":"561","name":"Status","label":false,"type":0, + "defaultType":{"id":0,"name":"Text"},"editable":true, + "minimumCardinality":0,"maximumCardinality":1,"position":3} + ]`)) + })) + defer server.Close() + + client := newTestAssetsClient(server, "workspace-456") + attributes, err := client.ObjectTypeAttributes(context.Background(), "9") + if err != nil { + t.Fatal(err) + } + if len(attributes) != 2 { + t.Fatalf("attributes = %#v", attributes) + } + if got, want := attributes[1].Name, "Status"; got != want { + t.Fatalf("name = %q, want %q", got, want) + } + if !attributes[0].Required() { + t.Error("minimumCardinality 1 did not read as required") + } + if attributes[1].Required() { + t.Error("minimumCardinality 0 read as required") + } +} + +func TestAssetsObjectType(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + requireBearer(t, request) + if request.URL.Path != "/ex/jira/cloud-123/jsm/assets/workspace/workspace-456/v1/objecttype/9" { + t.Fatalf("path = %q", request.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"9","name":"Mitarbeiter","objectSchemaId":"5"}`)) + })) + defer server.Close() + + client := newTestAssetsClient(server, "workspace-456") + objectType, err := client.ObjectType(context.Background(), "9") + if err != nil { + t.Fatal(err) + } + if got, want := objectType.Name, "Mitarbeiter"; got != want { + t.Fatalf("name = %q, want %q", got, want) + } +} + +// TestAssetsObjectTypeReadsNeedTypeAndAttributeScopes pins which scope each +// object-type read is gated on, so a token carrying only the object and schema +// scopes fails locally with the missing scope named rather than as an opaque +// 401 "scope does not match" from Atlassian. +func TestAssetsObjectTypeReadsNeedTypeAndAttributeScopes(t *testing.T) { + client := &AssetsClient{client: &Client{ + hostname: "test.atlassian.net", + tokens: &auth.TokenSet{Scopes: []string{auth.AssetsObjectReadScope, auth.AssetsSchemaReadScope}}, + }} + if _, err := client.ObjectType(context.Background(), "9"); err == nil { + t.Errorf("ObjectType() succeeded without %s", auth.AssetsTypeReadScope) + } + if _, err := client.ObjectTypeAttributes(context.Background(), "9"); err == nil { + t.Errorf("ObjectTypeAttributes() succeeded without %s", auth.AssetsAttributeReadScope) + } +} diff --git a/internal/auth/oauth.go b/internal/auth/oauth.go index 33bef12..6cb4997 100644 --- a/internal/auth/oauth.go +++ b/internal/auth/oauth.go @@ -14,9 +14,15 @@ import ( "time" ) +// Assets (CMDB) read scopes. Assets splits reading by resource kind rather than +// granting one blanket read, so object values, schema listings, object type +// definitions, and attribute definitions each need their own scope; a token +// holding only some of them gets a 401 "scope does not match" on the rest. const ( - AssetsObjectReadScope = "read:cmdb-object:jira" - AssetsSchemaReadScope = "read:cmdb-schema:jira" + AssetsObjectReadScope = "read:cmdb-object:jira" + AssetsSchemaReadScope = "read:cmdb-schema:jira" + AssetsTypeReadScope = "read:cmdb-type:jira" + AssetsAttributeReadScope = "read:cmdb-attribute:jira" ) const ( @@ -83,9 +89,12 @@ func DefaultScopes() []string { // request types (what the sm commands call); a bare read:servicedesk is // not a grantable Atlassian scope and is silently dropped from the token. "read:servicedesk-request", - // Jira Assets scopes - AQL/object reads and schema counts. + // Jira Assets scopes - AQL/object reads, schema counts, and object type + // definitions with their attributes. AssetsObjectReadScope, AssetsSchemaReadScope, + AssetsTypeReadScope, + AssetsAttributeReadScope, // Token refresh "offline_access", } diff --git a/internal/cmd/assets/assets.go b/internal/cmd/assets/assets.go index ab34e37..7a6d6b0 100644 --- a/internal/cmd/assets/assets.go +++ b/internal/cmd/assets/assets.go @@ -53,6 +53,7 @@ Assets request returns 403 after the app scopes changed, re-run cmd.AddCommand(newCmdCount(ios, opts)) cmd.AddCommand(newCmdAQL(ios, opts)) cmd.AddCommand(newCmdObject(ios, opts)) + cmd.AddCommand(newCmdAttributes(ios, opts)) return cmd } diff --git a/internal/cmd/assets/attributes.go b/internal/cmd/assets/attributes.go new file mode 100644 index 0000000..1c343e3 --- /dev/null +++ b/internal/cmd/assets/attributes.go @@ -0,0 +1,103 @@ +package assets + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/enthus-appdev/atl-cli/internal/api" + "github.com/enthus-appdev/atl-cli/internal/iostreams" + "github.com/enthus-appdev/atl-cli/internal/output" +) + +// attributeFlags renders the non-default properties of an attribute definition, +// omitting the ordinary ones so the column carries only what distinguishes this +// attribute from a plain optional single-value field. +func attributeFlags(attribute api.AssetObjectTypeAttribute) string { + var flags []string + if attribute.Required() { + flags = append(flags, "required") + } + if attribute.MaximumCardinality != 1 { + flags = append(flags, "multi") + } + if attribute.Label { + flags = append(flags, "label") + } + if attribute.UniqueAttribute { + flags = append(flags, "unique") + } + if attribute.System { + flags = append(flags, "system") + } + if attribute.Hidden { + flags = append(flags, "hidden") + } + if !attribute.Editable { + flags = append(flags, "read-only") + } + return strings.Join(flags, ", ") +} + +func newCmdAttributes(ios *iostreams.IOStreams, common *commonOptions) *cobra.Command { + var jsonOut bool + + cmd := &cobra.Command{ + Use: "attributes ", + Aliases: []string{"fields"}, + Short: "List the attributes defined on an Assets object type", + Long: `List every attribute an Assets object type defines, with its numeric id. + +This reads the type, not an object. An Assets object omits each attribute it +holds no value for, so a single object can never show that an attribute is +absent from its type - only this can. + +The object type's name is printed above the table. Check it names the type you +meant: a wrong id and a type that genuinely lacks an attribute look the same.`, + Example: ` atl --context prod jira assets attributes 9 + atl --context sandbox jira assets attributes 14 --json + + # Does this type carry a Status attribute at all? + atl --context prod jira assets attributes 9 --json | jq '.attributes[].name'`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := common.client() + if err != nil { + return err + } + + objectType, err := client.ObjectType(cmd.Context(), args[0]) + if err != nil { + return err + } + attributes, err := client.ObjectTypeAttributes(cmd.Context(), args[0]) + if err != nil { + return err + } + + if jsonOut { + return output.JSON(ios.Out, map[string]any{ + "objectType": objectType, + "attributes": attributes, + }) + } + + fmt.Fprintf(ios.Out, "%s\t%s\n", objectType.ID, terminalText(objectType.Name)) + rows := make([][]string, 0, len(attributes)) + for _, attribute := range attributes { + rows = append(rows, []string{ + attribute.ID, + terminalText(attribute.Name), + terminalText(attribute.DefaultType.Name), + attributeFlags(attribute), + }) + } + output.SimpleTable(ios.Out, []string{"ID", "ATTRIBUTE", "TYPE", "FLAGS"}, rows) + return nil + }, + } + + cmd.Flags().BoolVarP(&jsonOut, "json", "j", false, "Output as JSON") + return cmd +} diff --git a/internal/cmd/assets/attributes_test.go b/internal/cmd/assets/attributes_test.go new file mode 100644 index 0000000..179798c --- /dev/null +++ b/internal/cmd/assets/attributes_test.go @@ -0,0 +1,44 @@ +package assets + +import ( + "testing" + + "github.com/enthus-appdev/atl-cli/internal/api" +) + +func TestAttributeFlags(t *testing.T) { + var ordinary api.AssetObjectTypeAttribute + ordinary.MaximumCardinality = 1 + ordinary.Editable = true + + required := ordinary + required.MinimumCardinality = 1 + + multi := ordinary + multi.MaximumCardinality = -1 + + importKey := ordinary + importKey.System = true + importKey.Editable = false + importKey.UniqueAttribute = true + importKey.MinimumCardinality = 1 + + tests := []struct { + name string + attribute api.AssetObjectTypeAttribute + want string + }{ + {"ordinary attribute has no flags", ordinary, ""}, + {"minimum cardinality marks required", required, "required"}, + {"unbounded cardinality marks multi", multi, "multi"}, + {"import key shows every distinguishing flag", importKey, "required, unique, system, read-only"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := attributeFlags(tt.attribute); got != tt.want { + t.Errorf("attributeFlags() = %q, want %q", got, tt.want) + } + }) + } +} From 45dd8903f6d9dbc227916713039c7fb7a38c22e5 Mon Sep 17 00:00:00 2001 From: Hinne Stolzenberg Date: Fri, 18 Sep 2026 08:18:58 +0200 Subject: [PATCH 03/11] docs: document the Assets attribute read and the api version flag Also splits the Assets scopes onto their own line in the OAuth app setup, now that there are four of them. --- README.md | 41 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 54867ad..0d5e80b 100644 --- a/README.md +++ b/README.md @@ -237,6 +237,44 @@ the original author, and links to the focused original comment. It does not copy the original body into the reply. For manually authored comments, plain `@Display Name` is only text; use `@[Display Name]` for a real Jira mention. +### Jira Assets (CMDB) + +```bash +atl --context prod jira assets count # Object count per schema +atl --context prod jira assets aql '' # Run an AQL query +atl --context prod jira assets object # One object and its attribute values +atl --context prod jira assets attributes # Attributes defined on an object type +``` + +`assets attributes` (alias `fields`) reads the object type, not an object. An +Assets object omits every attribute it holds no value for, so reading one object +can never show that an attribute is absent from its type. It prints the object +type's name above the table: check it names the type you meant, because a wrong +id and a type that genuinely lacks an attribute look the same. + +```bash +# Does this object type carry a Status attribute at all? +atl --context prod jira assets attributes 9 --json | jq '.attributes[].name' +``` + +The object type and attribute reads need `read:cmdb-type:jira` and +`read:cmdb-attribute:jira`. Assets grants reads per resource kind, so a token +holding only `read:cmdb-object:jira` and `read:cmdb-schema:jira` gets +`401 "scope does not match"` on these endpoints. + +### Read-only REST passthrough + +```bash +atl --context prod jira api GET issue/PROJ-1234/editmeta # Endpoints atl does not model +atl --context prod jira api project/PROJ/securitylevel +atl --context prod jira api --api-version 2 issue/PROJ-1234?fields=description +``` + +Only GET is supported; atl deliberately does not expose write passthrough. +`--api-version` selects the Jira platform REST API version (`2` or `3`, default +`3`). Both expose the same resources and differ in how they carry rich text: v3 +uses ADF, v2 uses wiki markup. + ### Boards ```bash @@ -402,7 +440,8 @@ If authentication fails, verify your OAuth app configuration at https://develope **Jira API** (under "Jira API" in Developer Console): - Classic scopes: `read:jira-work`, `write:jira-work`, `read:jira-user` - - Granular scopes: `read:project:jira`, `read:issue-details:jira`, `read:cmdb-object:jira`, `read:cmdb-schema:jira` + - Granular scopes: `read:project:jira`, `read:issue-details:jira` + - Granular scopes for Assets (CMDB): `read:cmdb-object:jira`, `read:cmdb-schema:jira`, `read:cmdb-type:jira`, `read:cmdb-attribute:jira` - Granular scopes for boards/sprints/ranking: `read:board-scope:jira-software`, `write:board-scope:jira-software`, `read:issue:jira-software`, `write:issue:jira-software`, `read:sprint:jira-software`, `write:sprint:jira-software` **Confluence API** (under "Confluence API"): From 076177afa51f5dfa8a3ed53decb0a9662128afa4 Mon Sep 17 00:00:00 2001 From: Hinne Stolzenberg Date: Fri, 18 Sep 2026 08:23:16 +0200 Subject: [PATCH 04/11] fix: report both missing object type scopes at once, and stop guessing cardinality The type and its attributes are gated by different scopes, so a token holding one but not the other failed on the second request only after the first was fixed. Check both before either request. The multi-value label read every upper cardinality other than 1 as unbounded, which labels an absent or zero field as multi-valued. Assets spells unbounded as a negative number, so require that form or a stated bound above one. --- internal/api/assets.go | 12 ++++++++++++ internal/cmd/assets/attributes.go | 9 ++++++++- internal/cmd/assets/attributes_test.go | 14 +++++++++++--- 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/internal/api/assets.go b/internal/api/assets.go index 8c12f62..40bfeb4 100644 --- a/internal/api/assets.go +++ b/internal/api/assets.go @@ -238,6 +238,18 @@ func (a AssetObjectTypeAttribute) Required() bool { return a.MinimumCardinality > 0 } +// AssetsObjectTypeReadScopes are the scopes the two object type reads need +// between them. Assets gates the type and its attributes separately, so a +// caller that will make both requests checks both up front rather than +// discovering the second gap only after fixing the first. +var AssetsObjectTypeReadScopes = []string{auth.AssetsTypeReadScope, auth.AssetsAttributeReadScope} + +// RequireObjectTypeReadScopes reports every scope missing for reading an object +// type together with its attributes. +func (c *AssetsClient) RequireObjectTypeReadScopes() error { + return c.requireScopes(AssetsObjectTypeReadScopes...) +} + // ObjectType loads one object type by id. Its name is what tells a caller the id // addresses the type they meant: a wrong id and a type without the attribute // being looked for are otherwise indistinguishable. diff --git a/internal/cmd/assets/attributes.go b/internal/cmd/assets/attributes.go index 1c343e3..2082beb 100644 --- a/internal/cmd/assets/attributes.go +++ b/internal/cmd/assets/attributes.go @@ -19,7 +19,10 @@ func attributeFlags(attribute api.AssetObjectTypeAttribute) string { if attribute.Required() { flags = append(flags, "required") } - if attribute.MaximumCardinality != 1 { + // Assets spells an unbounded upper cardinality as a negative number. Treat + // only a stated bound above one, or that unbounded form, as multi-valued: an + // absent or zero field is not evidence of anything and must not be labeled. + if attribute.MaximumCardinality > 1 || attribute.MaximumCardinality < 0 { flags = append(flags, "multi") } if attribute.Label { @@ -67,6 +70,10 @@ meant: a wrong id and a type that genuinely lacks an attribute look the same.`, return err } + if err := client.RequireObjectTypeReadScopes(); err != nil { + return err + } + objectType, err := client.ObjectType(cmd.Context(), args[0]) if err != nil { return err diff --git a/internal/cmd/assets/attributes_test.go b/internal/cmd/assets/attributes_test.go index 179798c..020fbf9 100644 --- a/internal/cmd/assets/attributes_test.go +++ b/internal/cmd/assets/attributes_test.go @@ -14,8 +14,14 @@ func TestAttributeFlags(t *testing.T) { required := ordinary required.MinimumCardinality = 1 - multi := ordinary - multi.MaximumCardinality = -1 + unbounded := ordinary + unbounded.MaximumCardinality = -1 + + boundedMulti := ordinary + boundedMulti.MaximumCardinality = 5 + + unstated := ordinary + unstated.MaximumCardinality = 0 importKey := ordinary importKey.System = true @@ -30,7 +36,9 @@ func TestAttributeFlags(t *testing.T) { }{ {"ordinary attribute has no flags", ordinary, ""}, {"minimum cardinality marks required", required, "required"}, - {"unbounded cardinality marks multi", multi, "multi"}, + {"unbounded cardinality marks multi", unbounded, "multi"}, + {"a bound above one marks multi", boundedMulti, "multi"}, + {"an unstated upper bound claims nothing", unstated, ""}, {"import key shows every distinguishing flag", importKey, "required, unique, system, read-only"}, } From 05bb8cfca51dcb47043992e954cc18b51f5b7ddf Mon Sep 17 00:00:00 2001 From: Hinne Stolzenberg Date: Fri, 18 Sep 2026 08:25:45 +0200 Subject: [PATCH 05/11] test: pin that the combined scope check names every missing scope The contract of the combined pre-check is that one message lists every gap; a per-call check would have satisfied the type before failing on the attributes. --- internal/api/assets_test.go | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/internal/api/assets_test.go b/internal/api/assets_test.go index 0ba64cc..3772557 100644 --- a/internal/api/assets_test.go +++ b/internal/api/assets_test.go @@ -6,6 +6,7 @@ import ( "net/http" "net/http/httptest" "net/url" + "strings" "testing" "time" @@ -265,3 +266,31 @@ func TestAssetsObjectTypeReadsNeedTypeAndAttributeScopes(t *testing.T) { t.Errorf("ObjectTypeAttributes() succeeded without %s", auth.AssetsAttributeReadScope) } } + +// TestRequireObjectTypeReadScopesNamesEveryGap is the contract of the combined +// pre-check: one message lists every missing scope, so a caller does not fix one +// gap only to hit the next on the following run. +func TestRequireObjectTypeReadScopesNamesEveryGap(t *testing.T) { + client := &AssetsClient{client: &Client{ + hostname: "test.atlassian.net", + tokens: &auth.TokenSet{Scopes: []string{auth.AssetsObjectReadScope, auth.AssetsSchemaReadScope}}, + }} + + err := client.RequireObjectTypeReadScopes() + if err == nil { + t.Fatal("RequireObjectTypeReadScopes() succeeded without the object type scopes") + } + for _, scope := range AssetsObjectTypeReadScopes { + if !strings.Contains(err.Error(), scope) { + t.Errorf("error %q does not name the missing scope %q", err.Error(), scope) + } + } + + granted := &AssetsClient{client: &Client{ + hostname: "test.atlassian.net", + tokens: &auth.TokenSet{Scopes: AssetsObjectTypeReadScopes}, + }} + if err := granted.RequireObjectTypeReadScopes(); err != nil { + t.Fatalf("RequireObjectTypeReadScopes() rejected a token holding both scopes: %v", err) + } +} From f416b732f50fa773eb3b36dc35b5f71bae822c51 Mon Sep 17 00:00:00 2001 From: Hinne Stolzenberg Date: Fri, 18 Sep 2026 08:36:23 +0200 Subject: [PATCH 06/11] feat: show a Select attribute's allowed values A Select rejects any value outside its option list, so the options are what a caller has to match when writing the attribute. Reading the type without them answers that the attribute exists but not what may be put in it. --- README.md | 4 +++- internal/api/assets.go | 18 +++++++++++------- internal/api/assets_test.go | 5 ++++- internal/cmd/assets/attributes.go | 3 ++- 4 files changed, 20 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 0d5e80b..9a77ce3 100644 --- a/README.md +++ b/README.md @@ -250,7 +250,9 @@ atl --context prod jira assets attributes # Attributes defined Assets object omits every attribute it holds no value for, so reading one object can never show that an attribute is absent from its type. It prints the object type's name above the table: check it names the type you meant, because a wrong -id and a type that genuinely lacks an attribute look the same. +id and a type that genuinely lacks an attribute look the same. A Select +attribute lists its allowed values, which are the only values a write of it is +accepted with. ```bash # Does this object type carry a Status attribute at all? diff --git a/internal/api/assets.go b/internal/api/assets.go index 40bfeb4..aba61fa 100644 --- a/internal/api/assets.go +++ b/internal/api/assets.go @@ -224,13 +224,17 @@ type AssetObjectTypeAttribute struct { ID int `json:"id"` Name string `json:"name"` } `json:"defaultType"` - System bool `json:"system,omitempty"` - Editable bool `json:"editable,omitempty"` - Hidden bool `json:"hidden,omitempty"` - UniqueAttribute bool `json:"uniqueAttribute,omitempty"` - MinimumCardinality int `json:"minimumCardinality"` - MaximumCardinality int `json:"maximumCardinality"` - Position int `json:"position"` + // Options carries a Select attribute's allowed values as one comma-separated + // string. A write of any other value is rejected, so this is the vocabulary a + // caller has to match. + Options string `json:"options,omitempty"` + System bool `json:"system,omitempty"` + Editable bool `json:"editable,omitempty"` + Hidden bool `json:"hidden,omitempty"` + UniqueAttribute bool `json:"uniqueAttribute,omitempty"` + MinimumCardinality int `json:"minimumCardinality"` + MaximumCardinality int `json:"maximumCardinality"` + Position int `json:"position"` } // Required reports whether the attribute must carry at least one value. diff --git a/internal/api/assets_test.go b/internal/api/assets_test.go index 3772557..b6afc4f 100644 --- a/internal/api/assets_test.go +++ b/internal/api/assets_test.go @@ -204,7 +204,7 @@ func TestAssetsObjectTypeAttributes(t *testing.T) { "defaultType":{"id":0,"name":"Text"},"system":true,"editable":false, "minimumCardinality":1,"maximumCardinality":1,"position":0}, {"id":"561","name":"Status","label":false,"type":0, - "defaultType":{"id":0,"name":"Text"},"editable":true, + "defaultType":{"id":10,"name":"Select"},"editable":true,"options":"aktiv,inaktiv", "minimumCardinality":0,"maximumCardinality":1,"position":3} ]`)) })) @@ -227,6 +227,9 @@ func TestAssetsObjectTypeAttributes(t *testing.T) { if attributes[1].Required() { t.Error("minimumCardinality 0 read as required") } + if got, want := attributes[1].Options, "aktiv,inaktiv"; got != want { + t.Errorf("options = %q, want %q", got, want) + } } func TestAssetsObjectType(t *testing.T) { diff --git a/internal/cmd/assets/attributes.go b/internal/cmd/assets/attributes.go index 2082beb..755522e 100644 --- a/internal/cmd/assets/attributes.go +++ b/internal/cmd/assets/attributes.go @@ -97,10 +97,11 @@ meant: a wrong id and a type that genuinely lacks an attribute look the same.`, attribute.ID, terminalText(attribute.Name), terminalText(attribute.DefaultType.Name), + terminalText(attribute.Options), attributeFlags(attribute), }) } - output.SimpleTable(ios.Out, []string{"ID", "ATTRIBUTE", "TYPE", "FLAGS"}, rows) + output.SimpleTable(ios.Out, []string{"ID", "ATTRIBUTE", "TYPE", "OPTIONS", "FLAGS"}, rows) return nil }, } From 766df225eba55d4801332293307469672a20c1da Mon Sep 17 00:00:00 2001 From: Hinne Stolzenberg Date: Fri, 18 Sep 2026 08:39:54 +0200 Subject: [PATCH 07/11] chore: tidy added comments --- internal/api/assets_test.go | 7 ------- internal/api/client.go | 7 +++---- internal/api/client_test.go | 2 -- internal/api/jira_test.go | 2 -- internal/cmd/jira/api_test.go | 3 --- 5 files changed, 3 insertions(+), 18 deletions(-) diff --git a/internal/api/assets_test.go b/internal/api/assets_test.go index b6afc4f..4cb1a1e 100644 --- a/internal/api/assets_test.go +++ b/internal/api/assets_test.go @@ -253,10 +253,6 @@ func TestAssetsObjectType(t *testing.T) { } } -// TestAssetsObjectTypeReadsNeedTypeAndAttributeScopes pins which scope each -// object-type read is gated on, so a token carrying only the object and schema -// scopes fails locally with the missing scope named rather than as an opaque -// 401 "scope does not match" from Atlassian. func TestAssetsObjectTypeReadsNeedTypeAndAttributeScopes(t *testing.T) { client := &AssetsClient{client: &Client{ hostname: "test.atlassian.net", @@ -270,9 +266,6 @@ func TestAssetsObjectTypeReadsNeedTypeAndAttributeScopes(t *testing.T) { } } -// TestRequireObjectTypeReadScopesNamesEveryGap is the contract of the combined -// pre-check: one message lists every missing scope, so a caller does not fix one -// gap only to hit the next on the following run. func TestRequireObjectTypeReadScopesNamesEveryGap(t *testing.T) { client := &AssetsClient{client: &Client{ hostname: "test.atlassian.net", diff --git a/internal/api/client.go b/internal/api/client.go index ca87f5c..baea1ba 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -196,10 +196,9 @@ func (c *Client) JiraBaseURL() string { } // JiraBaseURLVersion returns the base URL for a specific Jira platform REST API -// version. Only the versions in SupportedJiraAPIVersions are accepted: the -// version is interpolated into the URL, so an unchecked value ("3/../../..") -// would leave the Jira REST namespace entirely, which no path-level guard on the -// caller's side can catch. +// version. Only versions in SupportedJiraAPIVersions are accepted; since the +// version is interpolated into the URL, an unchecked value ("3/../../..") +// escapes the Jira REST namespace entirely, bypassing any caller path-level guard. func (c *Client) JiraBaseURLVersion(version string) (string, error) { if err := ValidateJiraAPIVersion(version); err != nil { return "", err diff --git a/internal/api/client_test.go b/internal/api/client_test.go index 0511cbe..0a86ee1 100644 --- a/internal/api/client_test.go +++ b/internal/api/client_test.go @@ -449,8 +449,6 @@ func TestJiraBaseURLVersion(t *testing.T) { } } -// TestJiraBaseURLMatchesDefaultVersion keeps the unversioned helper and the -// declared default from drifting apart. func TestJiraBaseURLMatchesDefaultVersion(t *testing.T) { client := &Client{cloudID: "cloud-123"} versioned, err := client.JiraBaseURLVersion(DefaultJiraAPIVersion) diff --git a/internal/api/jira_test.go b/internal/api/jira_test.go index 0691dce..1327141 100644 --- a/internal/api/jira_test.go +++ b/internal/api/jira_test.go @@ -756,8 +756,6 @@ func TestRawGet_ValidationBeforeNetwork(t *testing.T) { } } -// TestRawGetVersionRejectsUnsupportedVersion proves the version is validated -// before any request is built, so a bad version cannot reach the network. func TestRawGetVersionRejectsUnsupportedVersion(t *testing.T) { service := NewJiraService(&Client{cloudID: "cloud-123"}) if _, err := service.RawGetVersion(context.Background(), "3/../../..", "issue/NX-1"); err == nil { diff --git a/internal/cmd/jira/api_test.go b/internal/cmd/jira/api_test.go index d34e7c5..91d9ebb 100644 --- a/internal/cmd/jira/api_test.go +++ b/internal/cmd/jira/api_test.go @@ -70,9 +70,6 @@ func TestWriteIndentedJSON_NonJSONFallback(t *testing.T) { } } -// TestNewCmdAPI_APIVersionFlag pins the default version and the rejection of an -// unsupported one. The rejection happens before the client is built, so it needs -// no HTTP. func TestNewCmdAPI_APIVersionFlag(t *testing.T) { cmd := NewCmdAPI(iostreams.Test()) flag := cmd.Flags().Lookup("api-version") From 95c29d231a1153d028219e03dce171ebf7273078 Mon Sep 17 00:00:00 2001 From: Hinne Stolzenberg Date: Fri, 18 Sep 2026 08:43:16 +0200 Subject: [PATCH 08/11] fix: name every attribute kind in the type column Only a Default attribute carries its kind in defaultType; a reference, user, group, or project attribute leaves it empty and rendered as a blank column. Fall back to the numeric type, reported verbatim because Assets does not publish that enum and a guessed label would be worse than a number. Also corrects the options doc: a Text attribute can carry a predefined value list too, so options are not confined to Select. --- README.md | 6 +++--- internal/api/assets.go | 19 ++++++++++++++++--- internal/api/assets_test.go | 15 +++++++++++++++ internal/cmd/assets/attributes.go | 2 +- 4 files changed, 35 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 9a77ce3..ed1ceba 100644 --- a/README.md +++ b/README.md @@ -250,9 +250,9 @@ atl --context prod jira assets attributes # Attributes defined Assets object omits every attribute it holds no value for, so reading one object can never show that an attribute is absent from its type. It prints the object type's name above the table: check it names the type you meant, because a wrong -id and a type that genuinely lacks an attribute look the same. A Select -attribute lists its allowed values, which are the only values a write of it is -accepted with. +id and a type that genuinely lacks an attribute look the same. An attribute +configured with a predefined value list shows that list, which is the vocabulary +a write of it is expected to use. ```bash # Does this object type carry a Status attribute at all? diff --git a/internal/api/assets.go b/internal/api/assets.go index aba61fa..b6054b3 100644 --- a/internal/api/assets.go +++ b/internal/api/assets.go @@ -224,9 +224,10 @@ type AssetObjectTypeAttribute struct { ID int `json:"id"` Name string `json:"name"` } `json:"defaultType"` - // Options carries a Select attribute's allowed values as one comma-separated - // string. A write of any other value is rejected, so this is the vocabulary a - // caller has to match. + // Options carries an attribute's predefined value list as one comma-separated + // string, for the attributes that have one. It is not confined to Select: a + // Text attribute can carry a list too, so the presence of options says what + // values are expected without implying the kind. Options string `json:"options,omitempty"` System bool `json:"system,omitempty"` Editable bool `json:"editable,omitempty"` @@ -237,6 +238,18 @@ type AssetObjectTypeAttribute struct { Position int `json:"position"` } +// TypeName names the attribute's kind for display. Only a Default attribute +// (type 0) carries its concrete kind in DefaultType; a reference, user, group, +// or project attribute leaves DefaultType empty and is identified by the numeric +// type alone. Assets does not publish that enum, so an unnamed kind is reported +// as its number rather than mapped to a label that cannot be verified. +func (a AssetObjectTypeAttribute) TypeName() string { + if a.DefaultType.Name != "" { + return a.DefaultType.Name + } + return fmt.Sprintf("type %d", a.Type) +} + // Required reports whether the attribute must carry at least one value. func (a AssetObjectTypeAttribute) Required() bool { return a.MinimumCardinality > 0 diff --git a/internal/api/assets_test.go b/internal/api/assets_test.go index 4cb1a1e..400a15b 100644 --- a/internal/api/assets_test.go +++ b/internal/api/assets_test.go @@ -232,6 +232,21 @@ func TestAssetsObjectTypeAttributes(t *testing.T) { } } +func TestAssetObjectTypeAttributeTypeName(t *testing.T) { + var text AssetObjectTypeAttribute + text.DefaultType.Name = "Text" + + var user AssetObjectTypeAttribute + user.Type = 2 + + if got, want := text.TypeName(), "Text"; got != want { + t.Errorf("TypeName() = %q, want %q", got, want) + } + if got, want := user.TypeName(), "type 2"; got != want { + t.Errorf("TypeName() = %q, want %q", got, want) + } +} + func TestAssetsObjectType(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { requireBearer(t, request) diff --git a/internal/cmd/assets/attributes.go b/internal/cmd/assets/attributes.go index 755522e..eeddc1c 100644 --- a/internal/cmd/assets/attributes.go +++ b/internal/cmd/assets/attributes.go @@ -96,7 +96,7 @@ meant: a wrong id and a type that genuinely lacks an attribute look the same.`, rows = append(rows, []string{ attribute.ID, terminalText(attribute.Name), - terminalText(attribute.DefaultType.Name), + terminalText(attribute.TypeName()), terminalText(attribute.Options), attributeFlags(attribute), }) From 92d8dd7951d303ae244c02fc2ff9c275265e6aa6 Mon Sep 17 00:00:00 2001 From: Hinne Stolzenberg Date: Fri, 18 Sep 2026 08:54:26 +0200 Subject: [PATCH 09/11] fix: label multi-valued only on the observed sentinel, and fail fast on a bad version Any negative upper cardinality read as unbounded; -1 is the only negative a live workspace produces, so an unobserved negative now reads as no flag rather than as a claim about a sentinel whose meaning Assets does not publish. The version allowlist ran only inside the API layer, after the authenticated client was built, so an unsupported version surfaced behind an auth error for anyone not logged in. The URL-building guard stays; this is the message. --- internal/cmd/assets/attributes.go | 12 ++++++++---- internal/cmd/assets/attributes_test.go | 4 ++++ internal/cmd/jira/api.go | 6 ++++++ 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/internal/cmd/assets/attributes.go b/internal/cmd/assets/attributes.go index eeddc1c..0231d3a 100644 --- a/internal/cmd/assets/attributes.go +++ b/internal/cmd/assets/attributes.go @@ -14,15 +14,19 @@ import ( // attributeFlags renders the non-default properties of an attribute definition, // omitting the ordinary ones so the column carries only what distinguishes this // attribute from a plain optional single-value field. +// unboundedCardinality is the upper-cardinality value Assets uses for "no limit". +const unboundedCardinality = -1 + func attributeFlags(attribute api.AssetObjectTypeAttribute) string { var flags []string if attribute.Required() { flags = append(flags, "required") } - // Assets spells an unbounded upper cardinality as a negative number. Treat - // only a stated bound above one, or that unbounded form, as multi-valued: an - // absent or zero field is not evidence of anything and must not be labeled. - if attribute.MaximumCardinality > 1 || attribute.MaximumCardinality < 0 { + // Assets spells an unbounded upper cardinality as -1; stated bounds observed + // in a live workspace are 1, 2, 50 and 100, and 0 never appears. Only those + // two forms are labeled, so an unobserved value reads as no flag rather than + // as a claim about a sentinel whose meaning is not published. + if attribute.MaximumCardinality > 1 || attribute.MaximumCardinality == unboundedCardinality { flags = append(flags, "multi") } if attribute.Label { diff --git a/internal/cmd/assets/attributes_test.go b/internal/cmd/assets/attributes_test.go index 020fbf9..a658b28 100644 --- a/internal/cmd/assets/attributes_test.go +++ b/internal/cmd/assets/attributes_test.go @@ -23,6 +23,9 @@ func TestAttributeFlags(t *testing.T) { unstated := ordinary unstated.MaximumCardinality = 0 + otherNegative := ordinary + otherNegative.MaximumCardinality = -2 + importKey := ordinary importKey.System = true importKey.Editable = false @@ -39,6 +42,7 @@ func TestAttributeFlags(t *testing.T) { {"unbounded cardinality marks multi", unbounded, "multi"}, {"a bound above one marks multi", boundedMulti, "multi"}, {"an unstated upper bound claims nothing", unstated, ""}, + {"a negative other than the sentinel claims nothing", otherNegative, ""}, {"import key shows every distinguishing flag", importKey, "required, unique, system, read-only"}, } diff --git a/internal/cmd/jira/api.go b/internal/cmd/jira/api.go index ab201dd..fd4a2b9 100644 --- a/internal/cmd/jira/api.go +++ b/internal/cmd/jira/api.go @@ -69,6 +69,12 @@ through v2 when a plain-text description is easier to work with than ADF.`, if strings.EqualFold(path, "GET") { return fmt.Errorf("missing \n\nExample: atl jira api GET issue/NX-1234/editmeta") } + // Validated here as well as in the API layer so an unsupported version is + // reported as such, rather than behind whatever error building the + // authenticated client happens to produce first. + if err := api.ValidateJiraAPIVersion(opts.APIVersion); err != nil { + return err + } opts.Path = path return runAPI(opts) }, From 2aa71ee4b80d9410608cd312e40d48f10a6001f4 Mon Sep 17 00:00:00 2001 From: Hinne Stolzenberg Date: Fri, 18 Sep 2026 09:02:49 +0200 Subject: [PATCH 10/11] fix: reject a non-numeric object type id, and move the cardinality rule to the API The id is interpolated into the request path and url.PathEscape leaves ";" intact, which is enough to append a path parameter and change how the server parses the request. The passthrough already blocks that class for Jira paths; the Assets reads had no equivalent guard. An id is always digits, so an allowlist closes it without chasing encodings. The scope union is now derived from the per-call requirements instead of restating them, so the up-front check cannot advertise less than the calls demand. IsMulti moves next to Required and TypeName, leaving the command with presentation only. Both reads wrap their error so a failure names which one. RawGet had no callers left and is removed rather than kept as a second entry point to the same URL construction. ObjectCount and Inherited lose omitempty, which was hiding a zero count and a false inherited from JSON consumers. --- README.md | 11 +++++++ internal/api/assets.go | 55 +++++++++++++++++++++++++++---- internal/api/assets_test.go | 37 +++++++++++++++++++++ internal/api/jira.go | 6 ---- internal/api/jira_test.go | 2 +- internal/cmd/assets/attributes.go | 15 +++------ 6 files changed, 101 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index ed1ceba..920f24e 100644 --- a/README.md +++ b/README.md @@ -264,6 +264,17 @@ The object type and attribute reads need `read:cmdb-type:jira` and holding only `read:cmdb-object:jira` and `read:cmdb-schema:jira` gets `401 "scope does not match"` on these endpoints. +Tokens issued before those two scopes were requested do not gain them, so +`assets attributes` reports the missing scope until each site is authenticated +again: + +```bash +atl auth login --hostname mycompany.atlassian.net +``` + +This affects only the new object type reads. `assets count`, `assets aql` and +`assets object` keep working on an older token. + ### Read-only REST passthrough ```bash diff --git a/internal/api/assets.go b/internal/api/assets.go index b6054b3..c7f5acf 100644 --- a/internal/api/assets.go +++ b/internal/api/assets.go @@ -6,6 +6,8 @@ import ( "fmt" "net/http" "net/url" + "regexp" + "slices" "strings" "github.com/enthus-appdev/atl-cli/internal/auth" @@ -255,11 +257,32 @@ func (a AssetObjectTypeAttribute) Required() bool { return a.MinimumCardinality > 0 } -// AssetsObjectTypeReadScopes are the scopes the two object type reads need -// between them. Assets gates the type and its attributes separately, so a -// caller that will make both requests checks both up front rather than -// discovering the second gap only after fixing the first. -var AssetsObjectTypeReadScopes = []string{auth.AssetsTypeReadScope, auth.AssetsAttributeReadScope} +// AssetsObjectTypeReadScopes is the union of what the object type reads need. +// It is derived from the per-call requirements rather than restated, so adding a +// scope to one of them cannot leave the up-front check advertising less than the +// calls actually demand. +var AssetsObjectTypeReadScopes = slices.Concat(objectTypeScopes, objectTypeAttributeScopes) + +// Assets gates the type and its attributes separately, so a caller that will +// make both requests checks both up front rather than discovering the second gap +// only after fixing the first. +var ( + objectTypeScopes = []string{auth.AssetsTypeReadScope} + objectTypeAttributeScopes = []string{auth.AssetsAttributeReadScope} +) + +// validObjectTypeID matches the only form an Assets object type id takes. The id +// is interpolated into the request path, and url.PathEscape leaves ";" intact, +// which is enough to append a path parameter and change how the server parses +// the request. An allowlist of digits closes that without chasing encodings. +var validObjectTypeID = regexp.MustCompile(`^[0-9]+$`) + +func checkObjectTypeID(objectTypeID string) error { + if validObjectTypeID.MatchString(objectTypeID) { + return nil + } + return fmt.Errorf("object type id must be numeric, got %q", objectTypeID) +} // RequireObjectTypeReadScopes reports every scope missing for reading an object // type together with its attributes. @@ -267,11 +290,26 @@ func (c *AssetsClient) RequireObjectTypeReadScopes() error { return c.requireScopes(AssetsObjectTypeReadScopes...) } +// IsMulti reports whether the attribute holds more than one value. Assets spells +// an unbounded upper cardinality as -1; stated bounds observed in a live +// workspace are 1, 2, 50 and 100, and 0 never appears. Only those two forms +// count, so an unobserved value reads as single-valued rather than as a claim +// about a sentinel whose meaning is not published. +func (a AssetObjectTypeAttribute) IsMulti() bool { + return a.MaximumCardinality > 1 || a.MaximumCardinality == unboundedCardinality +} + +// unboundedCardinality is the upper-cardinality value Assets uses for "no limit". +const unboundedCardinality = -1 + // ObjectType loads one object type by id. Its name is what tells a caller the id // addresses the type they meant: a wrong id and a type without the attribute // being looked for are otherwise indistinguishable. func (c *AssetsClient) ObjectType(ctx context.Context, objectTypeID string) (*AssetObjectType, error) { - if err := c.requireScopes(auth.AssetsTypeReadScope); err != nil { + if err := checkObjectTypeID(objectTypeID); err != nil { + return nil, err + } + if err := c.requireScopes(objectTypeScopes...); err != nil { return nil, err } base, err := c.v1(ctx) @@ -288,7 +326,10 @@ func (c *AssetsClient) ObjectType(ctx context.Context, objectTypeID string) (*As // ObjectTypeAttributes returns every attribute defined on an object type, // including the ones inherited from a parent type. func (c *AssetsClient) ObjectTypeAttributes(ctx context.Context, objectTypeID string) ([]AssetObjectTypeAttribute, error) { - if err := c.requireScopes(auth.AssetsAttributeReadScope); err != nil { + if err := checkObjectTypeID(objectTypeID); err != nil { + return nil, err + } + if err := c.requireScopes(objectTypeAttributeScopes...); err != nil { return nil, err } base, err := c.v1(ctx) diff --git a/internal/api/assets_test.go b/internal/api/assets_test.go index 400a15b..e7b5fdb 100644 --- a/internal/api/assets_test.go +++ b/internal/api/assets_test.go @@ -6,6 +6,7 @@ import ( "net/http" "net/http/httptest" "net/url" + "slices" "strings" "testing" "time" @@ -232,6 +233,42 @@ func TestAssetsObjectTypeAttributes(t *testing.T) { } } +func TestObjectTypeIDMustBeNumeric(t *testing.T) { + client := newTestAssetsClient(httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + t.Error("a rejected id reached the network") + w.WriteHeader(http.StatusOK) + })), "workspace-456") + + for _, id := range []string{"9;foo", "9%2f..", `9\x`, "../9", "9/attributes", "", "abc", " 9"} { + if _, err := client.ObjectType(context.Background(), id); err == nil { + t.Errorf("ObjectType(%q) accepted a non-numeric id", id) + } + if _, err := client.ObjectTypeAttributes(context.Background(), id); err == nil { + t.Errorf("ObjectTypeAttributes(%q) accepted a non-numeric id", id) + } + } +} + +// AssetsObjectTypeReadScopes must cover every scope the individual calls demand, +// or the up-front check passes a token the calls then reject. +func TestAssetsObjectTypeReadScopesCoverEveryCall(t *testing.T) { + for _, scope := range slices.Concat(objectTypeScopes, objectTypeAttributeScopes) { + if !slices.Contains(AssetsObjectTypeReadScopes, scope) { + t.Errorf("AssetsObjectTypeReadScopes omits %q", scope) + } + } +} + +func TestAssetObjectTypeAttributeIsMulti(t *testing.T) { + for max, want := range map[int]bool{-1: true, 0: false, 1: false, 2: true, 100: true, -2: false} { + var attribute AssetObjectTypeAttribute + attribute.MaximumCardinality = max + if got := attribute.IsMulti(); got != want { + t.Errorf("IsMulti() with maximumCardinality %d = %v, want %v", max, got, want) + } + } +} + func TestAssetObjectTypeAttributeTypeName(t *testing.T) { var text AssetObjectTypeAttribute text.DefaultType.Name = "Text" diff --git a/internal/api/jira.go b/internal/api/jira.go index 6d587c3..3eda359 100644 --- a/internal/api/jira.go +++ b/internal/api/jira.go @@ -766,12 +766,6 @@ func validateRawPath(apiPath string) error { return nil } -// RawGet performs a read-only GET against a path relative to the default Jira -// REST base and returns the raw JSON body. -func (s *JiraService) RawGet(ctx context.Context, apiPath string) (json.RawMessage, error) { - return s.RawGetVersion(ctx, DefaultJiraAPIVersion, apiPath) -} - // RawGetVersion performs a read-only GET against a path relative to the Jira // platform REST base of the given API version (e.g. "issue/NX-1/editmeta") and // returns the raw JSON body. It is the escape hatch for endpoints atl does not diff --git a/internal/api/jira_test.go b/internal/api/jira_test.go index 1327141..2331f30 100644 --- a/internal/api/jira_test.go +++ b/internal/api/jira_test.go @@ -747,7 +747,7 @@ func TestValidateRawPath(t *testing.T) { // hold even with a client that has no usable transport. func TestRawGet_ValidationBeforeNetwork(t *testing.T) { jira := NewJiraService(&Client{}) - _, err := jira.RawGet(context.Background(), "../../admin") + _, err := jira.RawGetVersion(context.Background(), DefaultJiraAPIVersion, "../../admin") if err == nil { t.Fatal("expected validation error for traversal path, got nil") } diff --git a/internal/cmd/assets/attributes.go b/internal/cmd/assets/attributes.go index 0231d3a..e0808c9 100644 --- a/internal/cmd/assets/attributes.go +++ b/internal/cmd/assets/attributes.go @@ -14,19 +14,12 @@ import ( // attributeFlags renders the non-default properties of an attribute definition, // omitting the ordinary ones so the column carries only what distinguishes this // attribute from a plain optional single-value field. -// unboundedCardinality is the upper-cardinality value Assets uses for "no limit". -const unboundedCardinality = -1 - func attributeFlags(attribute api.AssetObjectTypeAttribute) string { var flags []string if attribute.Required() { flags = append(flags, "required") } - // Assets spells an unbounded upper cardinality as -1; stated bounds observed - // in a live workspace are 1, 2, 50 and 100, and 0 never appears. Only those - // two forms are labeled, so an unobserved value reads as no flag rather than - // as a claim about a sentinel whose meaning is not published. - if attribute.MaximumCardinality > 1 || attribute.MaximumCardinality == unboundedCardinality { + if attribute.IsMulti() { flags = append(flags, "multi") } if attribute.Label { @@ -80,11 +73,11 @@ meant: a wrong id and a type that genuinely lacks an attribute look the same.`, objectType, err := client.ObjectType(cmd.Context(), args[0]) if err != nil { - return err + return fmt.Errorf("read object type %s: %w", args[0], err) } attributes, err := client.ObjectTypeAttributes(cmd.Context(), args[0]) if err != nil { - return err + return fmt.Errorf("read attributes of object type %s: %w", args[0], err) } if jsonOut { @@ -94,7 +87,7 @@ meant: a wrong id and a type that genuinely lacks an attribute look the same.`, }) } - fmt.Fprintf(ios.Out, "%s\t%s\n", objectType.ID, terminalText(objectType.Name)) + fmt.Fprintf(ios.Out, "%s\t%s\n", terminalText(objectType.ID), terminalText(objectType.Name)) rows := make([][]string, 0, len(attributes)) for _, attribute := range attributes { rows = append(rows, []string{ From 64576875656594a54b7344ebd9c6f1905586ac38 Mon Sep 17 00:00:00 2001 From: Hinne Stolzenberg Date: Fri, 18 Sep 2026 09:08:08 +0200 Subject: [PATCH 11/11] fix: sanitize the attribute id like every other cell in the row --- internal/cmd/assets/attributes.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/cmd/assets/attributes.go b/internal/cmd/assets/attributes.go index e0808c9..48f3bc3 100644 --- a/internal/cmd/assets/attributes.go +++ b/internal/cmd/assets/attributes.go @@ -91,7 +91,7 @@ meant: a wrong id and a type that genuinely lacks an attribute look the same.`, rows := make([][]string, 0, len(attributes)) for _, attribute := range attributes { rows = append(rows, []string{ - attribute.ID, + terminalText(attribute.ID), terminalText(attribute.Name), terminalText(attribute.TypeName()), terminalText(attribute.Options),