mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-10-10 19:59:04 +02:00
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:
co-authored by
copilot-swe-agent[bot]
parent
1bd6f006c8
commit
0ec6bfa191
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
Reference in New Issue
Block a user