refactor: authorize API routes with per-endpoint scopes (#1823)

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
This commit is contained in:
Elias Schneider
2026-10-09 21:33:14 +02:00
committed by GitHub
co-authored by copilot-swe-agent[bot]
parent 1bd6f006c8
commit 0ec6bfa191
48 changed files with 1422 additions and 681 deletions
+123
View File
@@ -0,0 +1,123 @@
package authz
import (
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
)
// Authenticator resolves a principal from one kind of credential
type Authenticator interface {
// Kind reports the kind of principal this authenticator produces
Kind() PrincipalKind
// Present reports whether the request carries this authenticator's credential at all, without validating it
Present(c *gin.Context) bool
// Authenticate validates the credential and resolves the principal
// It returns an error with code not_signed_in when the credential is invalid, so the next authenticator gets a chance
// Any other error, such as a disabled user, rejects the request
Authenticate(c *gin.Context) (*Principal, error)
}
// Middleware authenticates requests and enforces the scope each route declares
type Middleware struct {
authenticators []Authenticator
declared map[string]struct{}
}
// NewMiddleware creates the authorization middleware
// Authenticators are tried in order and the first one that resolves a principal wins
func NewMiddleware(authenticators ...Authenticator) *Middleware {
return &Middleware{
authenticators: authenticators,
declared: make(map[string]struct{}),
}
}
// Router wraps a gin router group so every route registered through it declares its access
func (m *Middleware) Router(group *gin.RouterGroup) *Router {
return &Router{group: group, auth: m}
}
// IsDeclared reports whether the route was registered through a Router or PublicRouter, so a test can compare gin's route table against the declarations
func (m *Middleware) IsDeclared(method, path string) bool {
_, ok := m.declared[routeKey(method, path)]
return ok
}
func (m *Middleware) declare(method, path string) {
m.declared[routeKey(method, path)] = struct{}{}
}
func routeKey(method, path string) string {
return method + " " + path
}
// require returns the handler that enforces the scope on a route
// An optional route lets requests without a usable credential through as anonymous instead of rejecting them
func (m *Middleware) require(scope Scope, optional bool) gin.HandlerFunc {
return func(c *gin.Context) {
principal, kindRejected, err := m.authenticate(c, scope)
if err != nil {
c.Abort()
_ = c.Error(err)
return
}
// Requests without a usable credential are anonymous
if principal == nil {
if optional {
c.Next()
return
}
c.Abort()
if kindRejected {
// Only API keys can be rejected by kind today, so the error tells the caller to use a browser session instead
_ = c.Error(apperror.APIKeyAuthNotAllowed())
return
}
_ = c.Error(apperror.NotSignedIn())
return
}
// A valid credential without the scope is forbidden even on optional routes, so a caller is never silently downgraded to anonymous
if !principal.Scopes.Has(scope) {
c.Abort()
_ = c.Error(apperror.MissingScope(string(scope)))
return
}
SetPrincipal(c, principal)
c.Next()
}
}
// authenticate resolves the principal from the first credential that can hold the scope and validates
// Credentials whose kind can never hold the scope are not validated at all, and kindRejected reports that one was present
func (m *Middleware) authenticate(c *gin.Context, scope Scope) (principal *Principal, kindRejected bool, err error) {
for _, authenticator := range m.authenticators {
if !authenticator.Present(c) {
continue
}
// Skip credentials that could never satisfy the route so they are not validated or marked as used
if !scope.GrantableTo(authenticator.Kind()) {
kindRejected = true
continue
}
principal, err = authenticator.Authenticate(c)
if err == nil {
return principal, false, nil
}
// An invalid credential falls through to the next authenticator, while a valid but rejected one ends the request
if !apperror.IsCode(err, apperror.CodeNotSignedIn) {
return nil, false, err
}
}
return nil, kindRejected, nil
}
+211
View File
@@ -0,0 +1,211 @@
package authz
import (
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
)
// fakeAuthenticator accepts any request carrying its header and resolves to the configured outcome
type fakeAuthenticator struct {
kind PrincipalKind
header string
user string
admin bool
err error
calls int
}
func (a *fakeAuthenticator) Kind() PrincipalKind {
return a.kind
}
func (a *fakeAuthenticator) Present(c *gin.Context) bool {
return c.GetHeader(a.header) != ""
}
func (a *fakeAuthenticator) Authenticate(*gin.Context) (*Principal, error) {
a.calls++
if a.err != nil {
return nil, a.err
}
principal := &Principal{Kind: a.kind, UserID: a.user, Scopes: UserScopes(a.admin, a.kind)}
if a.kind == KindSession {
principal.AuthenticationMethod = "passkey"
principal.AuthenticationTime = time.Unix(1700000000, 0)
}
return principal, nil
}
type middlewareResult struct {
status int
err error
principal Principal
}
// serve runs one request through a route that requires the scope and reports what the middleware decided
func serve(t *testing.T, m *Middleware, scope Scope, optional bool, headers map[string]string) middlewareResult {
t.Helper()
gin.SetMode(gin.TestMode)
var result middlewareResult
router := gin.New()
router.Use(func(c *gin.Context) {
c.Next()
if len(c.Errors) > 0 {
result.err = c.Errors.Last().Err
}
})
r := m.Router(router.Group("/api"))
if optional {
r = r.Optional()
}
r.GET("/route", scope, func(c *gin.Context) {
result.principal = PrincipalFrom(c)
c.Status(http.StatusNoContent)
})
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/route", nil)
for key, value := range headers {
req.Header.Set(key, value)
}
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, req)
result.status = recorder.Code
return result
}
func TestMiddlewareAuthorizes(t *testing.T) {
session := &fakeAuthenticator{kind: KindSession, header: "X-Session", user: "session-user"}
apiKey := &fakeAuthenticator{kind: KindAPIKey, header: "X-Key", user: "key-user", admin: true}
m := NewMiddleware(session, apiKey)
t.Run("attaches the principal", func(t *testing.T) {
result := serve(t, m, AccountRead, false, map[string]string{"X-Session": "1"})
require.Equal(t, http.StatusNoContent, result.status)
require.Equal(t, "session-user", result.principal.UserID)
require.Equal(t, KindSession, result.principal.Kind)
require.Equal(t, "passkey", result.principal.AuthenticationMethod)
})
t.Run("rejects missing credentials", func(t *testing.T) {
result := serve(t, m, AccountRead, false, nil)
require.True(t, apperror.IsCode(result.err, apperror.CodeNotSignedIn))
})
t.Run("rejects a principal without the scope and names the scope", func(t *testing.T) {
result := serve(t, m, UsersRead, false, map[string]string{"X-Session": "1"})
var appErr *apperror.Error
require.ErrorAs(t, result.err, &appErr)
require.Equal(t, apperror.CodeForbidden, appErr.Code())
require.Equal(t, string(UsersRead), appErr.Details()["required_scope"])
})
t.Run("the first authenticator that resolves wins", func(t *testing.T) {
result := serve(t, m, AccountRead, false, map[string]string{"X-Session": "1", "X-Key": "1"})
require.Equal(t, "session-user", result.principal.UserID)
})
t.Run("rejects a credential kind that can never hold the scope without validating it", func(t *testing.T) {
calls := apiKey.calls
result := serve(t, m, AccountSession, false, map[string]string{"X-Key": "1"})
require.True(t, apperror.IsCode(result.err, apperror.CodeAPIKeyAuthNotAllowed))
require.Equal(t, calls, apiKey.calls, "the API key must not be validated or marked as used")
})
t.Run("a valid credential of an allowed kind wins over a rejected kind", func(t *testing.T) {
result := serve(t, m, AccountSession, false, map[string]string{"X-Session": "1", "X-Key": "1"})
require.Equal(t, http.StatusNoContent, result.status)
require.Equal(t, "session-user", result.principal.UserID)
})
}
func TestMiddlewareFallsThroughInvalidCredentials(t *testing.T) {
invalidSession := &fakeAuthenticator{kind: KindSession, header: "X-Session", err: apperror.NotSignedIn()}
apiKey := &fakeAuthenticator{kind: KindAPIKey, header: "X-Key", user: "key-user"}
m := NewMiddleware(invalidSession, apiKey)
result := serve(t, m, AccountRead, false, map[string]string{"X-Session": "1", "X-Key": "1"})
require.Equal(t, http.StatusNoContent, result.status)
require.Equal(t, "key-user", result.principal.UserID)
}
func TestMiddlewareStopsOnRejectedCredentials(t *testing.T) {
disabledSession := &fakeAuthenticator{kind: KindSession, header: "X-Session", err: apperror.UserDisabled()}
apiKey := &fakeAuthenticator{kind: KindAPIKey, header: "X-Key", user: "key-user"}
m := NewMiddleware(disabledSession, apiKey)
for _, optional := range []bool{false, true} {
result := serve(t, m, AccountRead, optional, map[string]string{"X-Session": "1", "X-Key": "1"})
require.True(t, apperror.IsCode(result.err, apperror.CodeUserDisabled), "optional=%v", optional)
require.Zero(t, apiKey.calls)
}
}
func TestMiddlewareOptional(t *testing.T) {
session := &fakeAuthenticator{kind: KindSession, header: "X-Session", user: "session-user"}
invalidSession := &fakeAuthenticator{kind: KindSession, header: "X-Expired", err: apperror.NotSignedIn()}
apiKey := &fakeAuthenticator{kind: KindAPIKey, header: "X-Key", user: "key-user"}
m := NewMiddleware(session, invalidSession, apiKey)
t.Run("continues anonymously without credentials", func(t *testing.T) {
result := serve(t, m, AccountSession, true, nil)
require.Equal(t, http.StatusNoContent, result.status)
require.Equal(t, Principal{}, result.principal)
})
t.Run("continues anonymously with an invalid credential", func(t *testing.T) {
result := serve(t, m, AccountSession, true, map[string]string{"X-Expired": "1"})
require.Equal(t, http.StatusNoContent, result.status)
require.Equal(t, Principal{}, result.principal)
})
t.Run("ignores a credential kind that can never hold the scope", func(t *testing.T) {
result := serve(t, m, AccountSession, true, map[string]string{"X-Key": "1"})
require.Equal(t, http.StatusNoContent, result.status)
require.Equal(t, Principal{}, result.principal)
require.Zero(t, apiKey.calls)
})
t.Run("attaches the principal when signed in", func(t *testing.T) {
result := serve(t, m, AccountSession, true, map[string]string{"X-Session": "1"})
require.Equal(t, "session-user", result.principal.UserID)
})
t.Run("still rejects a signed-in principal without the scope", func(t *testing.T) {
result := serve(t, m, UsersRead, true, map[string]string{"X-Session": "1"})
require.True(t, apperror.IsCode(result.err, apperror.CodeForbidden))
})
}
func TestMiddlewarePassesThroughUnexpectedErrors(t *testing.T) {
failure := errors.New("database unavailable")
m := NewMiddleware(&fakeAuthenticator{kind: KindSession, header: "X-Session", err: failure})
result := serve(t, m, AccountRead, false, map[string]string{"X-Session": "1"})
require.ErrorIs(t, result.err, failure)
}
+36
View File
@@ -0,0 +1,36 @@
package authz
import (
"time"
"github.com/gin-gonic/gin"
)
const principalContextKey = "authz.principal"
// Principal is the authenticated caller of a request together with the scopes it holds
type Principal struct {
Kind PrincipalKind
UserID string
Scopes ScopeSet
// AuthenticationMethod and AuthenticationTime describe how the session was established and are only set for KindSession
AuthenticationMethod string
AuthenticationTime time.Time
}
// PrincipalFrom returns the principal the authorization middleware attached to the request
// Anonymous requests on optional and public routes get the zero Principal, whose UserID is empty
func PrincipalFrom(c *gin.Context) Principal {
value, _ := c.Get(principalContextKey)
if principal, ok := value.(*Principal); ok && principal != nil {
return *principal
}
return Principal{}
}
// SetPrincipal attaches the principal to the request
// The middleware calls it after authorizing a request, and tests use it to call handlers directly
func SetPrincipal(c *gin.Context, principal *Principal) {
c.Set(principalContextKey, principal)
}
+102
View File
@@ -0,0 +1,102 @@
package authz
import (
"fmt"
"net/http"
"path"
"strings"
"github.com/gin-gonic/gin"
)
// Router registers routes together with the scope each one requires
// Every route under /api must be registered through a Router or PublicRouter, which a test over the complete route table checks with IsDeclared
type Router struct {
group *gin.RouterGroup
auth *Middleware
optional bool
}
// Group returns a router for routes below the relative path
func (r *Router) Group(relativePath string) *Router {
return &Router{group: r.group.Group(relativePath), auth: r.auth, optional: r.optional}
}
// Optional returns a router whose routes let requests without a usable credential through as anonymous
// Handlers on these routes must check PrincipalFrom before relying on a signed-in user
func (r *Router) Optional() *Router {
return &Router{group: r.group, auth: r.auth, optional: true}
}
// Public returns a router for routes that require no authentication at all
func (r *Router) Public() *PublicRouter {
return &PublicRouter{group: r.group, auth: r.auth}
}
func (r *Router) GET(relativePath string, scope Scope, handlers ...gin.HandlerFunc) {
r.Handle(http.MethodGet, relativePath, scope, handlers...)
}
func (r *Router) POST(relativePath string, scope Scope, handlers ...gin.HandlerFunc) {
r.Handle(http.MethodPost, relativePath, scope, handlers...)
}
func (r *Router) PUT(relativePath string, scope Scope, handlers ...gin.HandlerFunc) {
r.Handle(http.MethodPut, relativePath, scope, handlers...)
}
func (r *Router) PATCH(relativePath string, scope Scope, handlers ...gin.HandlerFunc) {
r.Handle(http.MethodPatch, relativePath, scope, handlers...)
}
func (r *Router) DELETE(relativePath string, scope Scope, handlers ...gin.HandlerFunc) {
r.Handle(http.MethodDelete, relativePath, scope, handlers...)
}
// Handle registers a route that requires the scope, running authorization before every other handler of the route
func (r *Router) Handle(method, relativePath string, scope Scope, handlers ...gin.HandlerFunc) {
// An unknown scope can never be granted, so it is a programming error just like a duplicate route in gin
if !scope.Known() {
panic(fmt.Sprintf("route %s %s requires unknown scope %q", method, joinPaths(r.group.BasePath(), relativePath), scope))
}
chain := make([]gin.HandlerFunc, 0, len(handlers)+1)
chain = append(chain, r.auth.require(scope, r.optional))
chain = append(chain, handlers...)
r.group.Handle(method, relativePath, chain...)
r.auth.declare(method, joinPaths(r.group.BasePath(), relativePath))
}
// PublicRouter registers routes that require no authentication
// Routing them through here keeps public access an explicit decision instead of a missing middleware
type PublicRouter struct {
group *gin.RouterGroup
auth *Middleware
}
func (r *PublicRouter) GET(relativePath string, handlers ...gin.HandlerFunc) {
r.Handle(http.MethodGet, relativePath, handlers...)
}
func (r *PublicRouter) POST(relativePath string, handlers ...gin.HandlerFunc) {
r.Handle(http.MethodPost, relativePath, handlers...)
}
// Handle registers a public route
func (r *PublicRouter) Handle(method, relativePath string, handlers ...gin.HandlerFunc) {
r.group.Handle(method, relativePath, handlers...)
r.auth.declare(method, joinPaths(r.group.BasePath(), relativePath))
}
// joinPaths mirrors how gin builds a route's absolute path so declared routes match gin's route table
func joinPaths(absolutePath, relativePath string) string {
if relativePath == "" {
return absolutePath
}
finalPath := path.Join(absolutePath, relativePath)
if strings.HasSuffix(relativePath, "/") && !strings.HasSuffix(finalPath, "/") {
return finalPath + "/"
}
return finalPath
}
+75
View File
@@ -0,0 +1,75 @@
package authz
import (
"net/http"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func noop(c *gin.Context) {
c.Status(http.StatusNoContent)
}
func TestIsDeclared(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
m := NewMiddleware()
apiGroup := engine.Group("/api")
api := m.Router(apiGroup)
// Declare routes through every router variant
api.GET("/users", UsersRead, noop)
api.Group("/api-keys").POST("", AccountAPIKeysCreate, noop)
api.Group("/nested").Group("/deeper").DELETE("/:id", UsersWrite, noop)
api.Optional().GET("/optional", AccountSession, noop)
api.Public().POST("/signup", noop)
m.Router(engine.Group("/")).Optional().GET("/authorize", AccountSession, noop)
// Register routes past the routers
apiGroup.GET("/forgotten", noop)
apiGroup.POST("/users", noop)
declared := map[string]bool{}
for _, route := range engine.Routes() {
declared[route.Method+" "+route.Path] = m.IsDeclared(route.Method, route.Path)
}
require.Equal(t, map[string]bool{
"GET /api/users": true,
"POST /api/api-keys": true,
"DELETE /api/nested/deeper/:id": true,
"GET /api/optional": true,
"POST /api/signup": true,
"GET /authorize": true,
"GET /api/forgotten": false,
"POST /api/users": false,
}, declared)
}
func TestRouterRejectsUnknownScopes(t *testing.T) {
gin.SetMode(gin.TestMode)
r := NewMiddleware().Router(gin.New().Group("/api"))
require.PanicsWithValue(t, `route GET /api/users requires unknown scope "users:everything"`, func() {
r.GET("/users", Scope("users:everything"), noop)
})
}
func TestJoinPathsMatchesGin(t *testing.T) {
gin.SetMode(gin.TestMode)
for _, test := range []struct{ base, relative string }{
{"/api", ""},
{"/api", "/users"},
{"/api/", "users"},
{"/api", "/users/"},
{"/", "/authorize"},
{"/api/api-keys", ""},
} {
engine := gin.New()
engine.Group(test.base).GET(test.relative, noop)
require.Equal(t, engine.Routes()[0].Path, joinPaths(engine.Group(test.base).BasePath(), test.relative), "%+v", test)
}
}
+149
View File
@@ -0,0 +1,149 @@
package authz
// Scope is a permission that a principal must hold to call a route
// Keys follow the resource:action pattern and are valid RFC 6749 scope tokens so they can later appear in API key records and OAuth access tokens unchanged
type Scope string
// Account scopes cover the caller's own account and are held by every signed-in user
const (
AccountRead Scope = "account:read"
AccountWrite Scope = "account:write"
AccountPasskeys Scope = "account:passkeys"
AccountAPIKeys Scope = "account:api-keys"
AccountApps Scope = "account:apps"
AccountAuditLogs Scope = "account:audit-logs"
AccountSession Scope = "account:session"
AccountPasskeysEnroll Scope = "account:passkeys:enroll"
AccountAPIKeysCreate Scope = "account:api-keys:create"
)
// Admin scopes cover other users' data and the instance configuration
const (
UsersRead Scope = "users:read"
UsersWrite Scope = "users:write"
GroupsRead Scope = "groups:read"
GroupsWrite Scope = "groups:write"
OidcClientsRead Scope = "oidc-clients:read"
OidcClientsWrite Scope = "oidc-clients:write"
APIsRead Scope = "apis:read"
APIsWrite Scope = "apis:write"
ConfigRead Scope = "config:read"
ConfigWrite Scope = "config:write"
AuditLogsRead Scope = "audit-logs:read"
)
// Category groups scopes by whose data they reach
type Category int
const (
// CategoryAccount scopes act on the caller's own account
CategoryAccount Category = iota + 1
// CategoryAdmin scopes act on other users or on the instance
CategoryAdmin
)
// PrincipalKind identifies the kind of credential a principal authenticated with
// Kinds are bit flags so a scope can list every kind that may hold it
type PrincipalKind uint8
const (
// KindSession is a browser session established by signing in to Pocket ID
KindSession PrincipalKind = 1 << iota
// KindAPIKey is a personal API key sent in the X-API-Key header
KindAPIKey
// KindOAuthUser is an OAuth access token issued to a client acting on behalf of a user
KindOAuthUser
// KindOAuthClient is an OAuth access token issued to a client acting as itself through the client credentials grant
KindOAuthClient
)
// delegated lists the kinds that act for a user, which is every kind except a client acting as itself
const delegated = KindSession | KindAPIKey | KindOAuthUser
type definition struct {
scope Scope
category Category
grantableTo PrincipalKind
}
// catalog is the complete list of scopes
// grantableTo restricts which credential kinds can ever hold a scope, independent of the user's role
// Session-only scopes guard actions that must never be reachable with a long-lived or third-party credential, such as enrolling passkeys or minting API keys
var catalog = []definition{
{AccountRead, CategoryAccount, delegated},
{AccountWrite, CategoryAccount, delegated},
{AccountPasskeys, CategoryAccount, delegated},
{AccountAPIKeys, CategoryAccount, delegated},
{AccountApps, CategoryAccount, delegated},
{AccountAuditLogs, CategoryAccount, delegated},
{AccountSession, CategoryAccount, KindSession},
{AccountPasskeysEnroll, CategoryAccount, KindSession},
{AccountAPIKeysCreate, CategoryAccount, KindSession},
{UsersRead, CategoryAdmin, delegated},
{UsersWrite, CategoryAdmin, delegated},
{GroupsRead, CategoryAdmin, delegated},
{GroupsWrite, CategoryAdmin, delegated},
{OidcClientsRead, CategoryAdmin, delegated},
{OidcClientsWrite, CategoryAdmin, delegated},
{APIsRead, CategoryAdmin, delegated},
{APIsWrite, CategoryAdmin, delegated},
{ConfigRead, CategoryAdmin, delegated},
{ConfigWrite, CategoryAdmin, delegated},
{AuditLogsRead, CategoryAdmin, delegated},
}
var definitions = indexCatalog(catalog)
func indexCatalog(entries []definition) map[Scope]definition {
index := make(map[Scope]definition, len(entries))
for _, entry := range entries {
index[entry.scope] = entry
}
return index
}
// Known reports whether the scope is part of the catalog
func (s Scope) Known() bool {
_, ok := definitions[s]
return ok
}
// GrantableTo reports whether a principal of the given kind can ever hold the scope
func (s Scope) GrantableTo(kind PrincipalKind) bool {
return definitions[s].grantableTo&kind != 0
}
// ScopeSet is an unordered set of scopes
type ScopeSet map[Scope]struct{}
// NewScopeSet creates a set containing the given scopes
func NewScopeSet(scopes ...Scope) ScopeSet {
set := make(ScopeSet, len(scopes))
for _, scope := range scopes {
set[scope] = struct{}{}
}
return set
}
// Has reports whether the set contains the scope
func (s ScopeSet) Has(scope Scope) bool {
_, ok := s[scope]
return ok
}
// UserScopes returns the scopes a user holds when authenticated with a credential of the given kind
// The admin flag stands in for roles: admins hold every scope and other users hold the account scopes
func UserScopes(isAdmin bool, kind PrincipalKind) ScopeSet {
set := make(ScopeSet, len(catalog))
for _, entry := range catalog {
if entry.grantableTo&kind == 0 {
continue
}
if entry.category == CategoryAdmin && !isAdmin {
continue
}
set[entry.scope] = struct{}{}
}
return set
}
+70
View File
@@ -0,0 +1,70 @@
package authz
import (
"strings"
"testing"
"github.com/ory/fosite"
"github.com/stretchr/testify/require"
)
func TestCatalogInvariants(t *testing.T) {
// Scope keys end up in API key records and OAuth tokens, so they must be valid scope tokens that never collide with the identity scopes
reserved := []string{"openid", "profile", "email", "email_verified", "groups", "offline_access"}
seen := make(map[Scope]struct{}, len(catalog))
for _, entry := range catalog {
t.Run(string(entry.scope), func(t *testing.T) {
require.True(t, fosite.IsValidScopeToken(string(entry.scope)), "scope must be a valid RFC 6749 scope token")
require.NotContains(t, reserved, strings.ToLower(string(entry.scope)))
_, duplicate := seen[entry.scope]
require.False(t, duplicate, "scope is listed twice")
seen[entry.scope] = struct{}{}
require.Contains(t, []Category{CategoryAccount, CategoryAdmin}, entry.category)
require.NotZero(t, entry.grantableTo, "a scope nobody can hold can never pass a route")
// Account scopes are named after the account and admin scopes after a resource, so the prefix alone tells callers what they reach
require.Equal(t, entry.category == CategoryAccount, strings.HasPrefix(string(entry.scope), "account:"))
})
}
require.Len(t, definitions, len(catalog))
}
func TestClientCredentialsCannotHoldAnyScope(t *testing.T) {
// Service identities are not supported yet, see the scope-authorization plan
for _, entry := range catalog {
require.False(t, entry.scope.GrantableTo(KindOAuthClient), entry.scope)
}
}
func TestUserScopes(t *testing.T) {
t.Run("admins hold every scope their credential kind allows", func(t *testing.T) {
scopes := UserScopes(true, KindSession)
require.Len(t, scopes, len(catalog))
})
t.Run("regular users hold only account scopes", func(t *testing.T) {
scopes := UserScopes(false, KindSession)
for _, entry := range catalog {
require.Equal(t, entry.category == CategoryAccount, scopes.Has(entry.scope), entry.scope)
}
})
t.Run("API keys never hold session-only scopes, even for admins", func(t *testing.T) {
scopes := UserScopes(true, KindAPIKey)
require.True(t, scopes.Has(UsersWrite))
require.True(t, scopes.Has(AccountAPIKeys))
require.False(t, scopes.Has(AccountSession))
require.False(t, scopes.Has(AccountPasskeysEnroll))
require.False(t, scopes.Has(AccountAPIKeysCreate))
})
}
func TestUnknownScope(t *testing.T) {
unknown := Scope("unknown:scope")
require.False(t, unknown.Known())
require.False(t, unknown.GrantableTo(KindSession))
require.True(t, UsersRead.Known())
}