Files
coder/coderd/coderdtest/swaggerparser.go
T
Thomas Kosiewski 4bda39585d feat: add external API key scopes (#19916)
# Add support for low-level API key scopes

This PR adds support for fine-grained API key scopes based on RBAC resource:action pairs. It includes:

1. A new endpoint `/api/v2/auth/scopes` to list all public low-level API key scopes
2. Generated constants in the SDK for all public scopes
3. Tests to verify scope validation during token creation
4. Updated API documentation to reflect the expanded scope options

The implementation allows users to create API keys with specific permissions like `workspace:read` or `template:use` instead of only the legacy `all` or `application_connect` scopes.



Fixes #19847
2025-09-26 11:43:32 +02:00

374 lines
10 KiB
Go

package coderdtest
import (
"go/ast"
"go/parser"
"go/token"
"net/http"
"regexp"
"strings"
"testing"
"github.com/go-chi/chi/v5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/xerrors"
)
type SwaggerComment struct {
summary string
id string
security string
tags string
accept string
produce string
method string
router string
successes []response
failures []response
parameters []parameter
raw []*ast.Comment
}
type parameter struct {
name string
kind string
}
type response struct {
status string
kind string // {object} or {array}
model string
}
func ParseSwaggerComments(dirs ...string) ([]SwaggerComment, error) {
fileSet := token.NewFileSet()
var swaggerComments []SwaggerComment
for _, dir := range dirs {
nodes, err := parser.ParseDir(fileSet, dir, nil, parser.ParseComments)
if err != nil {
return nil, xerrors.Errorf(`parser.ParseDir failed for "%s": %w`, dir, err)
}
for _, node := range nodes {
ast.Inspect(node, func(n ast.Node) bool {
commentGroup, ok := n.(*ast.CommentGroup)
if !ok {
return true
}
var isSwaggerComment bool
for _, line := range commentGroup.List {
text := strings.TrimSpace(line.Text)
if strings.HasPrefix(text, "//") && strings.Contains(text, "@Router") {
isSwaggerComment = true
break
}
}
if isSwaggerComment {
swaggerComments = append(swaggerComments, parseSwaggerComment(commentGroup))
}
return true
})
}
}
return swaggerComments, nil
}
func parseSwaggerComment(commentGroup *ast.CommentGroup) SwaggerComment {
c := SwaggerComment{
raw: commentGroup.List,
parameters: []parameter{},
successes: []response{},
failures: []response{},
}
for _, line := range commentGroup.List {
// "// @<annotationName> [args...]" -> []string{"//", "@<annotationName>", "args..."}
splitN := strings.SplitN(strings.TrimSpace(line.Text), " ", 3)
if len(splitN) < 3 {
continue // comment prefix without any content
}
if !strings.HasPrefix(splitN[1], "@") {
continue // not a swagger annotation
}
annotationName := splitN[1]
annotationArgs := splitN[2]
args := strings.Split(splitN[2], " ")
switch annotationName {
case "@Router":
c.router = args[0]
c.method = args[1][1 : len(args[1])-1]
case "@Success", "@Failure":
var r response
if len(args) > 0 {
r.status = args[0]
}
if len(args) > 1 {
r.kind = args[1]
}
if len(args) > 2 {
r.model = args[2]
}
if annotationName == "@Success" {
c.successes = append(c.successes, r)
} else if annotationName == "@Failure" {
c.failures = append(c.failures, r)
}
case "@Param":
p := parameter{
name: args[0],
kind: args[1],
}
c.parameters = append(c.parameters, p)
case "@Summary":
c.summary = annotationArgs
case "@ID":
c.id = annotationArgs
case "@Tags":
c.tags = annotationArgs
case "@Security":
c.security = annotationArgs
case "@Accept":
c.accept = annotationArgs
case "@Produce":
c.produce = annotationArgs
}
}
return c
}
func VerifySwaggerDefinitions(t *testing.T, router chi.Router, swaggerComments []SwaggerComment) {
assertUniqueRoutes(t, swaggerComments)
assertSingleAnnotations(t, swaggerComments)
err := chi.Walk(router, func(method, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error {
method = strings.ToLower(method)
if route != "/" && strings.HasSuffix(route, "/") {
route = route[:len(route)-1]
}
t.Run(method+" "+route, func(t *testing.T) {
t.Parallel()
// This route is for compatibility purposes and is not documented.
if route == "/workspaceagents/me/metadata" {
return
}
c := findSwaggerCommentByMethodAndRoute(swaggerComments, method, route)
assert.NotNil(t, c, "Missing @Router annotation")
if c == nil {
return // do not fail next assertion for this route
}
assertConsistencyBetweenRouteIDAndSummary(t, *c)
assertSuccessOrFailureDefined(t, *c)
assertRequiredAnnotations(t, *c)
assertGoCommentFirst(t, *c)
assertPathParametersDefined(t, *c)
assertSecurityDefined(t, *c)
assertAccept(t, *c)
assertProduce(t, *c)
})
return nil
})
require.NoError(t, err, "chi.Walk should not fail")
}
func assertUniqueRoutes(t *testing.T, comments []SwaggerComment) {
m := map[string]struct{}{}
for _, c := range comments {
key := c.method + " " + c.router
_, alreadyDefined := m[key]
assert.False(t, alreadyDefined, "defined route must be unique (method: %s, route: %s)", c.method, c.router)
if !alreadyDefined {
m[key] = struct{}{}
}
}
}
var uniqueAnnotations = []string{"@ID", "@Summary", "@Tags", "@Router"}
func assertSingleAnnotations(t *testing.T, comments []SwaggerComment) {
for _, comment := range comments {
counters := map[string]int{}
for _, line := range comment.raw {
splitN := strings.SplitN(strings.TrimSpace(line.Text), " ", 3)
if len(splitN) < 2 {
continue // comment prefix without any content
}
if !strings.HasPrefix(splitN[1], "@") {
continue // not a swagger annotation
}
annotation := splitN[1]
if _, ok := counters[annotation]; !ok {
counters[annotation] = 0
}
counters[annotation]++
}
for _, annotation := range uniqueAnnotations {
v := counters[annotation]
assert.Equal(t, 1, v, "%s annotation for route %s must be defined only once", annotation, comment.router)
}
}
}
func findSwaggerCommentByMethodAndRoute(comments []SwaggerComment, method, route string) *SwaggerComment {
for _, c := range comments {
if c.method == method && c.router == route {
return &c
}
}
return nil
}
var nonAlphanumericRegex = regexp.MustCompile(`[^a-zA-Z0-9-]+`)
func assertConsistencyBetweenRouteIDAndSummary(t *testing.T, comment SwaggerComment) {
exp := strings.ToLower(comment.summary)
exp = strings.ReplaceAll(exp, " ", "-")
exp = nonAlphanumericRegex.ReplaceAllString(exp, "")
assert.Equal(t, exp, comment.id, "Router ID must match summary")
}
func assertSuccessOrFailureDefined(t *testing.T, comment SwaggerComment) {
assert.True(t, len(comment.successes) > 0 || len(comment.failures) > 0, "At least one @Success or @Failure annotation must be defined")
}
func assertRequiredAnnotations(t *testing.T, comment SwaggerComment) {
assert.NotEmpty(t, comment.id, "@ID must be defined")
assert.NotEmpty(t, comment.summary, "@Summary must be defined")
assert.NotEmpty(t, comment.tags, "@Tags must be defined")
assert.NotEmpty(t, comment.router, "@Router must be defined")
}
func assertGoCommentFirst(t *testing.T, comment SwaggerComment) {
var inSwaggerBlock bool
for _, line := range comment.raw {
text := strings.TrimSpace(line.Text)
if inSwaggerBlock {
if !strings.HasPrefix(text, "// @") && !strings.HasPrefix(text, "// nolint:") {
assert.Fail(t, "Go function comment must be placed before swagger comments")
return
}
}
if strings.HasPrefix(text, "// @Summary") {
inSwaggerBlock = true
}
}
}
var urlParameterRegexp = regexp.MustCompile(`{[^{}]*}`)
func assertPathParametersDefined(t *testing.T, comment SwaggerComment) {
matches := urlParameterRegexp.FindAllString(comment.router, -1)
if matches == nil {
return // router does not require any parameters
}
for _, m := range matches {
var matched bool
for _, p := range comment.parameters {
if p.kind == "path" && "{"+p.name+"}" == m {
matched = true
break
}
}
if !matched {
assert.Failf(t, "Missing @Param annotation", "Path parameter: %s", m)
}
}
}
func assertSecurityDefined(t *testing.T, comment SwaggerComment) {
authorizedSecurityTags := []string{
"CoderSessionToken",
"CoderProvisionerKey",
}
if comment.router == "/updatecheck" ||
comment.router == "/buildinfo" ||
comment.router == "/" ||
comment.router == "/auth/scopes" ||
comment.router == "/users/login" ||
comment.router == "/users/otp/request" ||
comment.router == "/users/otp/change-password" ||
comment.router == "/init-script/{os}/{arch}" {
return // endpoints do not require authorization
}
assert.Containsf(t, authorizedSecurityTags, comment.security, "@Security must be either of these options: %v", authorizedSecurityTags)
}
func assertAccept(t *testing.T, comment SwaggerComment) {
var hasRequestBody bool
for _, c := range comment.parameters {
if c.name == "request" && c.kind == "body" ||
c.name == "file" && c.kind == "formData" {
hasRequestBody = true
break
}
}
var hasAccept bool
if comment.accept != "" {
hasAccept = true
}
if comment.method == "get" {
assert.Empty(t, comment.accept, "GET route does not require the @Accept annotation")
assert.False(t, hasRequestBody, "GET route does not require the request body")
} else {
assert.False(t, hasRequestBody && !hasAccept, "Route with the request body requires the @Accept annotation")
assert.False(t, !hasRequestBody && hasAccept, "Route with @Accept annotation requires the request body or file formData parameter")
}
}
var allowedProduceTypes = []string{"json", "text/event-stream", "text/html"}
func assertProduce(t *testing.T, comment SwaggerComment) {
var hasResponseModel bool
for _, r := range comment.successes {
if r.model != "" {
hasResponseModel = true
break
}
}
if hasResponseModel {
assert.True(t, comment.produce != "", "Route must have @Produce annotation as it responds with a model structure")
assert.Contains(t, allowedProduceTypes, comment.produce, "@Produce value is limited to specific types: %s", strings.Join(allowedProduceTypes, ","))
} else {
if (comment.router == "/workspaceagents/me/app-health" && comment.method == "post") ||
(comment.router == "/workspaceagents/me/startup" && comment.method == "post") ||
(comment.router == "/workspaceagents/me/startup/logs" && comment.method == "patch") ||
(comment.router == "/licenses/{id}" && comment.method == "delete") ||
(comment.router == "/debug/coordinator" && comment.method == "get") ||
(comment.router == "/debug/tailnet" && comment.method == "get") ||
(comment.router == "/workspaces/{workspace}/acl" && comment.method == "patch") ||
(comment.router == "/init-script/{os}/{arch}" && comment.method == "get") {
return // Exception: HTTP 200 is returned without response entity
}
assert.Truef(t, comment.produce == "", "Response model is undefined, so we can't predict the content type: %v", comment)
}
}