mirror of
https://github.com/coder/coder.git
synced 2026-06-03 04:58:23 +00:00
4bda39585d
# 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
374 lines
10 KiB
Go
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)
|
|
}
|
|
}
|