diff --git a/README.md b/README.md index 54867ad..920f24e 100644 --- a/README.md +++ b/README.md @@ -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 '' # 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. 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 @@ -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"): diff --git a/internal/api/assets.go b/internal/api/assets.go index 8a66b42..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" @@ -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 +} + +// 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 +} diff --git a/internal/api/assets_test.go b/internal/api/assets_test.go index 4d7a578..e7b5fdb 100644 --- a/internal/api/assets_test.go +++ b/internal/api/assets_test.go @@ -6,6 +6,8 @@ import ( "net/http" "net/http/httptest" "net/url" + "slices" + "strings" "testing" "time" @@ -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{ @@ -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") + + 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) + } +} + +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) + } +} diff --git a/internal/api/client.go b/internal/api/client.go index b264311..baea1ba 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,28 @@ 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 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 + } + 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..0a86ee1 100644 --- a/internal/api/client_test.go +++ b/internal/api/client_test.go @@ -423,3 +423,39 @@ 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) + } + } +} + +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..3eda359 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,21 @@ 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. -func (s *JiraService) RawGet(ctx context.Context, apiPath string) (json.RawMessage, error) { +// 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..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") } @@ -755,3 +755,10 @@ func TestRawGet_ValidationBeforeNetwork(t *testing.T) { t.Errorf("error = %q, want a traversal-rejection message", err.Error()) } } + +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/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..48f3bc3 --- /dev/null +++ b/internal/cmd/assets/attributes.go @@ -0,0 +1,108 @@ +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.IsMulti() { + 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 + } + + if err := client.RequireObjectTypeReadScopes(); err != nil { + return err + } + + objectType, err := client.ObjectType(cmd.Context(), args[0]) + if err != nil { + return fmt.Errorf("read object type %s: %w", args[0], err) + } + attributes, err := client.ObjectTypeAttributes(cmd.Context(), args[0]) + if err != nil { + return fmt.Errorf("read attributes of object type %s: %w", args[0], err) + } + + if jsonOut { + return output.JSON(ios.Out, map[string]any{ + "objectType": objectType, + "attributes": attributes, + }) + } + + 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{ + terminalText(attribute.ID), + terminalText(attribute.Name), + terminalText(attribute.TypeName()), + terminalText(attribute.Options), + attributeFlags(attribute), + }) + } + output.SimpleTable(ios.Out, []string{"ID", "ATTRIBUTE", "TYPE", "OPTIONS", "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..a658b28 --- /dev/null +++ b/internal/cmd/assets/attributes_test.go @@ -0,0 +1,56 @@ +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 + + unbounded := ordinary + unbounded.MaximumCardinality = -1 + + boundedMulti := ordinary + boundedMulti.MaximumCardinality = 5 + + unstated := ordinary + unstated.MaximumCardinality = 0 + + otherNegative := ordinary + otherNegative.MaximumCardinality = -2 + + 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", 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"}, + } + + 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) + } + }) + } +} diff --git a/internal/cmd/jira/api.go b/internal/cmd/jira/api.go index 23923a1..fd4a2b9 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), @@ -61,11 +69,20 @@ is supported — atl deliberately does not expose write passthrough. 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) }, } + 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 +95,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..91d9ebb 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,25 @@ func TestWriteIndentedJSON_NonJSONFallback(t *testing.T) { t.Errorf("non-JSON body not echoed; got: %s", buf.String()) } } + +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()) + } +}