Skip to content
Merged
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
54 changes: 53 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,57 @@ 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 '<query>' # Run an AQL query
atl --context prod jira assets object <id> # One object and its attribute values
atl --context prod jira assets attributes <object-type-id> # 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. 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?
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.

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
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
Expand Down Expand Up @@ -402,7 +453,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"):
Expand Down
144 changes: 144 additions & 0 deletions internal/api/assets.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import (
"fmt"
"net/http"
"net/url"
"regexp"
"slices"
"strings"

"github.com/enthus-appdev/atl-cli/internal/auth"
Expand Down Expand Up @@ -198,3 +200,145 @@ 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"`
// 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"`
Hidden bool `json:"hidden,omitempty"`
UniqueAttribute bool `json:"uniqueAttribute,omitempty"`
MinimumCardinality int `json:"minimumCardinality"`
MaximumCardinality int `json:"maximumCardinality"`
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
}
Comment thread
Hinne1 marked this conversation as resolved.

// 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.
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 := checkObjectTypeID(objectTypeID); err != nil {
return nil, err
}
if err := c.requireScopes(objectTypeScopes...); 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 := checkObjectTypeID(objectTypeID); err != nil {
return nil, err
}
if err := c.requireScopes(objectTypeAttributeScopes...); 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
}
159 changes: 158 additions & 1 deletion internal/api/assets_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import (
"net/http"
"net/http/httptest"
"net/url"
"slices"
"strings"
"testing"
"time"

Expand All @@ -20,7 +22,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{
Expand Down Expand Up @@ -185,3 +192,153 @@ 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":10,"name":"Select"},"editable":true,"options":"aktiv,inaktiv",
"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")
}
if got, want := attributes[1].Options, "aktiv,inaktiv"; got != want {
t.Errorf("options = %q, want %q", got, want)
}
}

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")
Comment on lines +236 to +240

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

The test server created by httptest.NewServer is passed directly to newTestAssetsClient without being assigned to a variable, which prevents it from being closed. This leaks the test server and its background listener. Assign the server to a variable and defer server.Close() to clean up resources properly.

func TestObjectTypeIDMustBeNumeric(t *testing.T) {
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		t.Error("a rejected id reached the network")
		w.WriteHeader(http.StatusOK)
	}))
	defer server.Close()
	client := newTestAssetsClient(server, "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"

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)
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)
}
}

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)
}
}
Comment thread
Hinne1 marked this conversation as resolved.

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)
}
}
Loading
Loading