diff --git a/.changes/unreleased/BUG FIXES-20260729-114501.yaml b/.changes/unreleased/BUG FIXES-20260729-114501.yaml new file mode 100644 index 0000000..3e1dd5f --- /dev/null +++ b/.changes/unreleased/BUG FIXES-20260729-114501.yaml @@ -0,0 +1,3 @@ +kind: BUG FIXES +body: "Delete permissions now resolve correctly for resources with non-standard API paths (e.g. `explorer-saved-queries`, `run-tasks`)" +time: 2026-07-29T11:45:01.000000-06:00 diff --git a/internal/commands/api/api.go b/internal/commands/api/api.go index 0793fc8..7cd6b35 100644 --- a/internal/commands/api/api.go +++ b/internal/commands/api/api.go @@ -454,7 +454,10 @@ func RunAPI(ctx context.Context, opts *Opts) error { // session can authorize a noninteractive delete; otherwise a human must // confirm at an interactive terminal. if method == http.MethodDelete { - class := execsession.ClassFromPath(opts.URL.Path) + class := opts.ResourceType + if class == "" { + class = execsession.ClassFromPath(opts.URL.Path) + } decision := execsession.Decision{} if opts.Authorizer != nil { diff --git a/internal/pkg/execsession/permissions.go b/internal/pkg/execsession/permissions.go index 7df009e..eb1c125 100644 --- a/internal/pkg/execsession/permissions.go +++ b/internal/pkg/execsession/permissions.go @@ -88,7 +88,10 @@ func ClassFromPath(p string) string { } // The class is the collection segment immediately preceding the final id. - return segments[len(segments)-2] + // Map it to the actual resource type (handles non-standard paths where + // the URL segment differs from the type name, e.g. "views" → "explorer-saved-queries"). + segment := segments[len(segments)-2] + return resource.TypeFromPathSegment(segment) } func isAllDigits(s string) bool { diff --git a/internal/pkg/execsession/permissions_test.go b/internal/pkg/execsession/permissions_test.go index 665df74..e4ebadd 100644 --- a/internal/pkg/execsession/permissions_test.go +++ b/internal/pkg/execsession/permissions_test.go @@ -55,6 +55,10 @@ func TestClassFromPath(t *testing.T) { {name: "short path collection only", path: "/workspaces", want: ""}, {name: "empty", path: "", want: ""}, {name: "root", path: "/", want: ""}, + {name: "non-standard explorer-saved-queries", path: "/organizations/my-org/explorer/views/sq-abc", want: "explorer-saved-queries"}, + {name: "non-standard explorer-saved-queries with api prefix", path: "/api/v2/organizations/my-org/explorer/views/sq-abc", want: "explorer-saved-queries"}, + {name: "non-standard run-tasks", path: "/tasks/task-abc", want: "run-tasks"}, + {name: "non-standard github-app-installations", path: "/github-app/installation/ghain-abc", want: "github-app-installations"}, } for _, tc := range cases { diff --git a/internal/pkg/resource/registry.go b/internal/pkg/resource/registry.go index dac8480..1764079 100644 --- a/internal/pkg/resource/registry.go +++ b/internal/pkg/resource/registry.go @@ -560,3 +560,37 @@ func IsResolvableType(typeName string) bool { } return r.Resolvable } + +// TypeFromPathSegment maps a URL path segment to its resource type. For most +// resources the segment matches the type (e.g. "workspaces" → "workspaces"), +// but some have non-standard URL structures where the path segment differs +// from the type name (e.g. "views" → "explorer-saved-queries", "tasks" → "run-tasks"). +// Returns the segment unchanged if no mapping is found. +func TypeFromPathSegment(segment string) string { + // Check all registered resources for a PathGet that uses this segment + // in a non-standard way (segment != type). + for _, r := range registry { + if r.PathGet == "" { + continue + } + // Extract the collection segment immediately before {id} in PathGet + parts := strings.Split(strings.Trim(r.PathGet, "/"), "/") + if len(parts) < 2 { + continue + } + // Find the last non-placeholder segment before the trailing {id} + var collectionSegment string + for i := len(parts) - 1; i >= 0; i-- { + if strings.HasPrefix(parts[i], "{") { + continue + } + collectionSegment = parts[i] + break + } + if collectionSegment == segment && collectionSegment != r.Type { + return r.Type + } + } + // No non-standard mapping found, return the segment as-is + return segment +} diff --git a/internal/pkg/resource/registry_test.go b/internal/pkg/resource/registry_test.go index 152c18e..446388e 100644 --- a/internal/pkg/resource/registry_test.go +++ b/internal/pkg/resource/registry_test.go @@ -367,3 +367,35 @@ func TestRegistryInvariants(t *testing.T) { } }) } + +func TestTypeFromPathSegment(t *testing.T) { + t.Parallel() + + cases := []struct { + segment string + want string + }{ + // Standard cases where segment == type + {segment: "workspaces", want: "workspaces"}, + {segment: "projects", want: "projects"}, + {segment: "runs", want: "runs"}, + {segment: "vars", want: "vars"}, + {segment: "organizations", want: "organizations"}, + + // Non-standard cases where the PathGet uses a different segment + {segment: "views", want: "explorer-saved-queries"}, + {segment: "tasks", want: "run-tasks"}, + {segment: "installation", want: "github-app-installations"}, + + // Unknown segment returns as-is + {segment: "unknown-segment", want: "unknown-segment"}, + {segment: "", want: ""}, + } + + for _, tc := range cases { + t.Run(tc.segment, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.want, TypeFromPathSegment(tc.segment)) + }) + } +}