mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-10-02 07:49:04 +02:00
feat: add OAuth APIs with scoped permissions (#1542)
Co-authored-by: Alessandro (Ale) Segala <43508+ItalyPaleAle@users.noreply.github.com>
This commit is contained in:
co-authored by
Alessandro Segala
parent
0b2706a488
commit
09d196f7c5
@@ -0,0 +1,66 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
)
|
||||
|
||||
// apiResponseDto is the full representation of an API including its permissions
|
||||
type apiResponseDto struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Resource string `json:"resource"`
|
||||
CreatedAt datatype.DateTime `json:"createdAt"`
|
||||
Permissions []apiPermissionResponseDto `json:"permissions"`
|
||||
}
|
||||
|
||||
type apiPermissionResponseDto struct {
|
||||
ID string `json:"id"`
|
||||
Key string `json:"key"`
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
// apiListItemDto is the lightweight representation used in list responses
|
||||
type apiListItemDto struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Resource string `json:"resource"`
|
||||
CreatedAt datatype.DateTime `json:"createdAt"`
|
||||
PermissionCount int `json:"permissionCount"`
|
||||
}
|
||||
|
||||
// apiCreateDto is the payload for creating an API
|
||||
// The resource identifier is only accepted here because changing it later would invalidate every token already minted for the API
|
||||
type apiCreateDto struct {
|
||||
Name string `json:"name" binding:"required,min=1,max=50" unorm:"nfc"`
|
||||
Resource string `json:"resource" binding:"required,resource_uri,max=350" unorm:"nfc"`
|
||||
}
|
||||
|
||||
// apiUpdateDto is the payload for updating an API
|
||||
// The resource identifier is intentionally not updatable
|
||||
type apiUpdateDto struct {
|
||||
Name string `json:"name" binding:"required,min=1,max=50" unorm:"nfc"`
|
||||
}
|
||||
|
||||
type apiPermissionInputDto struct {
|
||||
Key string `json:"key" binding:"required,min=1,max=128" unorm:"nfc"`
|
||||
Name string `json:"name" binding:"required,min=1,max=50" unorm:"nfc"`
|
||||
Description *string `json:"description" binding:"omitempty,max=200"`
|
||||
}
|
||||
|
||||
// apiPermissionsUpdateDto replaces the full permission set of an API
|
||||
type apiPermissionsUpdateDto struct {
|
||||
Permissions []apiPermissionInputDto `json:"permissions" binding:"omitempty,dive"`
|
||||
}
|
||||
|
||||
// clientApiAccessDto is the set of API permissions a client is allowed to request, split by subject type
|
||||
// User-delegated permissions may be requested on behalf of a signed-in user, client permissions may be obtained by the client itself through the client credentials grant
|
||||
type clientApiAccessDto struct {
|
||||
UserDelegatedPermissionIDs []string `json:"userDelegatedPermissionIds"`
|
||||
ClientPermissionIDs []string `json:"clientPermissionIds"`
|
||||
}
|
||||
|
||||
type clientApiAccessUpdateDto struct {
|
||||
UserDelegatedPermissionIDs []string `json:"userDelegatedPermissionIds" binding:"omitempty,dive,required"`
|
||||
ClientPermissionIDs []string `json:"clientPermissionIds" binding:"omitempty,dive,required"`
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
)
|
||||
|
||||
type handler struct {
|
||||
service *Service
|
||||
}
|
||||
|
||||
func newHandler(service *Service) *handler {
|
||||
return &handler{service: service}
|
||||
}
|
||||
|
||||
// list godoc
|
||||
// @Summary List APIs
|
||||
// @Description Get a paginated list of APIs with optional search and sorting
|
||||
// @Tags APIs
|
||||
// @Produce json
|
||||
// @Param search query string false "Search term to filter APIs by name or resource"
|
||||
// @Param pagination[page] query int false "Page number for pagination" default(1)
|
||||
// @Param pagination[limit] query int false "Number of items per page" default(20)
|
||||
// @Param sort[column] query string false "Column to sort by"
|
||||
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
|
||||
// @Success 200 {object} dto.Paginated[apiListItemDto]
|
||||
// @Router /api/apis [get]
|
||||
func (h *handler) list(c *gin.Context) {
|
||||
search := c.Query("search")
|
||||
listRequestOptions := utils.ParseListRequestOptions(c)
|
||||
|
||||
apis, pagination, err := h.service.List(c.Request.Context(), search, listRequestOptions)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
items := make([]apiListItemDto, len(apis))
|
||||
for i, api := range apis {
|
||||
var item apiListItemDto
|
||||
if err := dto.MapStruct(api, &item); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
item.Resource = api.Audience
|
||||
item.PermissionCount = len(api.Permissions)
|
||||
items[i] = item
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, dto.Paginated[apiListItemDto]{
|
||||
Data: items,
|
||||
Pagination: pagination,
|
||||
})
|
||||
}
|
||||
|
||||
// get godoc
|
||||
// @Summary Get API by ID
|
||||
// @Description Retrieve a single API including its permissions
|
||||
// @Tags APIs
|
||||
// @Produce json
|
||||
// @Param id path string true "API ID"
|
||||
// @Success 200 {object} apiResponseDto
|
||||
// @Router /api/apis/{id} [get]
|
||||
func (h *handler) get(c *gin.Context) {
|
||||
api, err := h.service.Get(c.Request.Context(), nil, c.Param("id"))
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
h.respond(c, http.StatusOK, api)
|
||||
}
|
||||
|
||||
// create godoc
|
||||
// @Summary Create API
|
||||
// @Description Create a new API resource server
|
||||
// @Tags APIs
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param api body apiCreateDto true "API information"
|
||||
// @Success 201 {object} apiResponseDto "Created API"
|
||||
// @Router /api/apis [post]
|
||||
func (h *handler) create(c *gin.Context) {
|
||||
var input apiCreateDto
|
||||
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
api, err := h.service.Create(c.Request.Context(), input)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
h.respond(c, http.StatusCreated, api)
|
||||
}
|
||||
|
||||
// update godoc
|
||||
// @Summary Update API
|
||||
// @Description Update an existing API by ID
|
||||
// @Tags APIs
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "API ID"
|
||||
// @Param api body apiUpdateDto true "API information"
|
||||
// @Success 200 {object} apiResponseDto "Updated API"
|
||||
// @Router /api/apis/{id} [put]
|
||||
func (h *handler) update(c *gin.Context) {
|
||||
var input apiUpdateDto
|
||||
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
api, err := h.service.Update(c.Request.Context(), c.Param("id"), input)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
h.respond(c, http.StatusOK, api)
|
||||
}
|
||||
|
||||
// delete godoc
|
||||
// @Summary Delete API
|
||||
// @Description Delete an API by ID
|
||||
// @Tags APIs
|
||||
// @Param id path string true "API ID"
|
||||
// @Success 204 "No Content"
|
||||
// @Router /api/apis/{id} [delete]
|
||||
func (h *handler) delete(c *gin.Context) {
|
||||
if err := h.service.Delete(c.Request.Context(), c.Param("id")); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// updatePermissions godoc
|
||||
// @Summary Update API permissions
|
||||
// @Description Replace the full set of permissions for an API
|
||||
// @Tags APIs
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "API ID"
|
||||
// @Param permissions body apiPermissionsUpdateDto true "Permissions to set"
|
||||
// @Success 200 {object} apiResponseDto "Updated API"
|
||||
// @Router /api/apis/{id}/permissions [put]
|
||||
func (h *handler) updatePermissions(c *gin.Context) {
|
||||
var input apiPermissionsUpdateDto
|
||||
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
api, err := h.service.UpdatePermissions(c.Request.Context(), c.Param("id"), input)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
h.respond(c, http.StatusOK, api)
|
||||
}
|
||||
|
||||
// getClientAccess godoc
|
||||
// @Summary Get client API access
|
||||
// @Description Get the API permissions an OIDC client is allowed to request, split into user-delegated and client (machine-to-machine) access
|
||||
// @Tags APIs
|
||||
// @Produce json
|
||||
// @Param clientId path string true "OIDC Client ID"
|
||||
// @Success 200 {object} clientApiAccessDto
|
||||
// @Router /api/api-access/{clientId} [get]
|
||||
func (h *handler) getClientAccess(c *gin.Context) {
|
||||
access, err := h.service.GetClientAPIAccess(c.Request.Context(), c.Param("clientId"))
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, newClientApiAccessDto(access))
|
||||
}
|
||||
|
||||
// updateClientAccess godoc
|
||||
// @Summary Update client API access
|
||||
// @Description Replace the API permissions an OIDC client is allowed to request, split into user-delegated and client (machine-to-machine) access
|
||||
// @Tags APIs
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param clientId path string true "OIDC Client ID"
|
||||
// @Param access body clientApiAccessUpdateDto true "Allowed permission IDs per subject type"
|
||||
// @Success 200 {object} clientApiAccessDto
|
||||
// @Router /api/api-access/{clientId} [put]
|
||||
func (h *handler) updateClientAccess(c *gin.Context) {
|
||||
var input clientApiAccessUpdateDto
|
||||
err := c.ShouldBindJSON(&input)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
applied, err := h.service.SetClientAPIAccess(c.Request.Context(), c.Param("clientId"), ClientAPIAccess(input))
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, newClientApiAccessDto(applied))
|
||||
}
|
||||
|
||||
// newClientApiAccessDto always serializes both permission lists as arrays rather than null
|
||||
func newClientApiAccessDto(access ClientAPIAccess) clientApiAccessDto {
|
||||
dto := clientApiAccessDto(access)
|
||||
if dto.UserDelegatedPermissionIDs == nil {
|
||||
dto.UserDelegatedPermissionIDs = []string{}
|
||||
}
|
||||
if dto.ClientPermissionIDs == nil {
|
||||
dto.ClientPermissionIDs = []string{}
|
||||
}
|
||||
return dto
|
||||
}
|
||||
|
||||
func (h *handler) respond(c *gin.Context, status int, api API) {
|
||||
var responseDto apiResponseDto
|
||||
if err := dto.MapStruct(api, &responseDto); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
responseDto.Resource = api.Audience
|
||||
c.JSON(status, responseDto)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/oidc"
|
||||
)
|
||||
|
||||
type API struct {
|
||||
model.Base
|
||||
|
||||
Name string `sortable:"true"`
|
||||
Audience string `sortable:"true"`
|
||||
UpdatedAt *datatype.DateTime
|
||||
|
||||
Permissions []Permission `gorm:"foreignKey:APIID;references:ID;constraint:OnDelete:CASCADE"`
|
||||
}
|
||||
|
||||
type Permission struct {
|
||||
model.Base
|
||||
|
||||
APIID string `gorm:"column:api_id"`
|
||||
Key string `sortable:"true"`
|
||||
Name string
|
||||
Description *string
|
||||
}
|
||||
|
||||
func (Permission) TableName() string { return "api_permissions" }
|
||||
|
||||
type OidcClientAllowedAPIPermission struct {
|
||||
OidcClientID string
|
||||
APIPermissionID string
|
||||
SubjectType oidc.SubjectType
|
||||
}
|
||||
|
||||
func (OidcClientAllowedAPIPermission) TableName() string {
|
||||
return "oidc_clients_allowed_api_permissions"
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/oidc"
|
||||
)
|
||||
|
||||
type Dependencies struct {
|
||||
DB *gorm.DB
|
||||
// Issuer is the OpenID Provider issuer URL, reserved so a custom API cannot claim it as its audience
|
||||
Issuer string
|
||||
}
|
||||
|
||||
type Module struct {
|
||||
service *Service
|
||||
handler *handler
|
||||
}
|
||||
|
||||
func New(deps Dependencies) *Module {
|
||||
service := newService(deps.DB, deps.Issuer)
|
||||
return &Module{
|
||||
service: service,
|
||||
handler: newHandler(service),
|
||||
}
|
||||
}
|
||||
|
||||
// ClientAPIScopes implements the OIDC module's APIAccessProvider interface
|
||||
func (m *Module) ClientAPIScopes(ctx context.Context, tx *gorm.DB, clientID string) (scopes []string, audiences []string, err error) {
|
||||
return m.service.ClientAPIScopesAndAudiences(ctx, tx, clientID)
|
||||
}
|
||||
|
||||
// AllowedScopesForAudience implements the OIDC module's APIAccessProvider interface
|
||||
func (m *Module) AllowedScopesForAudience(ctx context.Context, clientID, audience string, subjectType oidc.SubjectType) (scopes []string, apiExists bool, err error) {
|
||||
return m.service.AllowedScopesForAudience(ctx, clientID, audience, subjectType)
|
||||
}
|
||||
|
||||
// DescribePermissions implements the OIDC module's APIAccessProvider interface
|
||||
func (m *Module) DescribePermissions(ctx context.Context, audience string, keys []string) ([]oidc.PermissionInfo, error) {
|
||||
permissions, err := m.service.DescribePermissions(ctx, audience, keys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
infos := make([]oidc.PermissionInfo, len(permissions))
|
||||
for i, permission := range permissions {
|
||||
description := ""
|
||||
if permission.Description != nil {
|
||||
description = *permission.Description
|
||||
}
|
||||
infos[i] = oidc.PermissionInfo{Key: permission.Key, Name: permission.Name, Description: description}
|
||||
}
|
||||
|
||||
return infos, nil
|
||||
}
|
||||
|
||||
// RegisterRoutes mounts the admin CRUD endpoints
|
||||
// adminAuth is passed in as a gin handler so the module does not import internal/middleware
|
||||
func (m *Module) RegisterRoutes(apiGroup *gin.RouterGroup, adminAuth gin.HandlerFunc) {
|
||||
apis := apiGroup.Group("/apis")
|
||||
apis.Use(adminAuth)
|
||||
apis.GET("", m.handler.list)
|
||||
apis.POST("", m.handler.create)
|
||||
apis.GET("/:id", m.handler.get)
|
||||
apis.PUT("/:id", m.handler.update)
|
||||
apis.DELETE("/:id", m.handler.delete)
|
||||
apis.PUT("/:id/permissions", m.handler.updatePermissions)
|
||||
|
||||
// The per-client API-access allow-list lives on a separate path so it does not collide with the /apis/:id wildcard
|
||||
access := apiGroup.Group("/api-access")
|
||||
access.Use(adminAuth)
|
||||
access.GET("/:clientId", m.handler.getClientAccess)
|
||||
access.PUT("/:clientId", m.handler.updateClientAccess)
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ory/fosite"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/oidc"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// isPermissionKeyReserved reports whether the key is a scope or claim name owned by Pocket ID's built-in identity layer
|
||||
// A custom API permission must not reuse one, otherwise its scope string would collide with a standard OIDC scope or claim
|
||||
func isPermissionKeyReserved(key string) bool {
|
||||
switch strings.ToLower(key) {
|
||||
case "openid", "profile", "email", "email_verified", "groups", "offline_access":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// isValidPermissionKey reports whether the key consists only of RFC 6749 scope-token characters, which are printable ASCII without space, double-quote or backslash
|
||||
// This keeps a key safe as a space-delimited value in the token scope claim and free of the control character used to qualify consent records
|
||||
func isValidPermissionKey(key string) bool {
|
||||
return fosite.IsValidScopeToken(key)
|
||||
}
|
||||
|
||||
// Service holds the business logic for managing APIs and their permissions
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
issuer string
|
||||
}
|
||||
|
||||
func newService(db *gorm.DB, issuer string) *Service {
|
||||
return &Service{db: db, issuer: issuer}
|
||||
}
|
||||
|
||||
// isIssuerAudience reports whether the audience refers to Pocket ID itself (the issuer)
|
||||
// A custom API must not claim the issuer as its audience, otherwise its tokens would be indistinguishable from Pocket ID's own identity tokens
|
||||
func isIssuerAudience(audience, issuer string) bool {
|
||||
return issuer != "" && strings.ToLower(strings.TrimRight(audience, "/")) == issuer
|
||||
}
|
||||
|
||||
func (s *Service) List(ctx context.Context, search string, listRequestOptions utils.ListRequestOptions) (apis []API, response utils.PaginationResponse, err error) {
|
||||
query := s.db.
|
||||
WithContext(ctx).
|
||||
Preload("Permissions").
|
||||
Model(&API{})
|
||||
|
||||
if listRequestOptions.Sort.Column == "resource" {
|
||||
listRequestOptions.Sort.Column = "audience"
|
||||
}
|
||||
|
||||
if search != "" {
|
||||
like := "%" + search + "%"
|
||||
query = query.Where("name LIKE ? OR audience LIKE ?", like, like)
|
||||
}
|
||||
|
||||
response, err = utils.PaginateFilterAndSort(listRequestOptions, query, &apis)
|
||||
return apis, response, err
|
||||
}
|
||||
|
||||
// Get loads an API and its permissions
|
||||
func (s *Service) Get(ctx context.Context, tx *gorm.DB, id string) (api API, err error) {
|
||||
query := s.db.WithContext(ctx)
|
||||
if tx != nil {
|
||||
query = tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"})
|
||||
}
|
||||
|
||||
err = query.
|
||||
Preload("Permissions").
|
||||
Where("id = ?", id).
|
||||
First(&api).
|
||||
Error
|
||||
return api, err
|
||||
}
|
||||
|
||||
func (s *Service) Create(ctx context.Context, input apiCreateDto) (api API, err error) {
|
||||
// Reject the issuer as an audience so a custom API cannot impersonate Pocket ID's own identity tokens
|
||||
if isIssuerAudience(input.Resource, s.issuer) {
|
||||
return API{}, &common.ValidationError{Message: "the resource is reserved by Pocket ID and cannot be used for a custom API"}
|
||||
}
|
||||
|
||||
api = API{
|
||||
Name: input.Name,
|
||||
Audience: input.Resource,
|
||||
}
|
||||
|
||||
err = s.db.WithContext(ctx).Create(&api).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
||||
return API{}, &common.AlreadyInUseError{Property: "resource"}
|
||||
}
|
||||
return API{}, err
|
||||
}
|
||||
|
||||
return api, nil
|
||||
}
|
||||
|
||||
func (s *Service) Update(ctx context.Context, id string, input apiUpdateDto) (api API, err error) {
|
||||
tx := s.db.Begin()
|
||||
defer func() {
|
||||
tx.Rollback()
|
||||
}()
|
||||
|
||||
api, err = s.Get(ctx, tx, id)
|
||||
if err != nil {
|
||||
return API{}, err
|
||||
}
|
||||
|
||||
api.Name = input.Name
|
||||
api.UpdatedAt = new(datatype.DateTime(time.Now()))
|
||||
|
||||
err = tx.WithContext(ctx).Save(&api).Error
|
||||
if err != nil {
|
||||
return API{}, err
|
||||
}
|
||||
|
||||
if err = tx.Commit().Error; err != nil {
|
||||
return API{}, err
|
||||
}
|
||||
|
||||
return api, nil
|
||||
}
|
||||
|
||||
func (s *Service) Delete(ctx context.Context, id string) error {
|
||||
tx := s.db.Begin()
|
||||
defer func() {
|
||||
tx.Rollback()
|
||||
}()
|
||||
|
||||
api, err := s.Get(ctx, tx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = s.deletePermissions(ctx, tx, collectIDs(api.Permissions)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = tx.WithContext(ctx).Delete(&API{}, "id = ?", id).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Commit().Error
|
||||
}
|
||||
|
||||
// UpdatePermissions replaces the full permission set of an API, matching existing permissions by key
|
||||
// Unchanged keys keep their grants, removed keys and their client grants are deleted, and new keys are inserted
|
||||
func (s *Service) UpdatePermissions(ctx context.Context, id string, input apiPermissionsUpdateDto) (api API, err error) {
|
||||
tx := s.db.Begin()
|
||||
defer func() {
|
||||
tx.Rollback()
|
||||
}()
|
||||
|
||||
api, err = s.Get(ctx, tx, id)
|
||||
if err != nil {
|
||||
return API{}, err
|
||||
}
|
||||
|
||||
// Reject keys with invalid characters, that collide with Pocket ID's reserved scopes and claims, or that repeat within the request before persisting anything
|
||||
// A duplicate key would otherwise be silently coalesced last-wins into the map below, dropping a row behind a 200
|
||||
seen := make(map[string]struct{}, len(input.Permissions))
|
||||
for _, permission := range input.Permissions {
|
||||
if !isValidPermissionKey(permission.Key) {
|
||||
return API{}, &common.ValidationError{Message: fmt.Sprintf("the permission key %q contains invalid characters", permission.Key)}
|
||||
}
|
||||
if isPermissionKeyReserved(permission.Key) {
|
||||
return API{}, &common.ValidationError{Message: fmt.Sprintf("the permission key %q is reserved by Pocket ID", permission.Key)}
|
||||
}
|
||||
_, ok := seen[permission.Key]
|
||||
if ok {
|
||||
return API{}, &common.ValidationError{Message: fmt.Sprintf("the permission key %q is listed more than once", permission.Key)}
|
||||
}
|
||||
seen[permission.Key] = struct{}{}
|
||||
}
|
||||
|
||||
existing := make(map[string]Permission, len(api.Permissions))
|
||||
for _, p := range api.Permissions {
|
||||
existing[p.Key] = p
|
||||
}
|
||||
|
||||
wanted := make(map[string]apiPermissionInputDto, len(input.Permissions))
|
||||
var removedIDs []string
|
||||
for _, in := range input.Permissions {
|
||||
wanted[in.Key] = in
|
||||
}
|
||||
|
||||
// Delete permissions whose key is no longer wanted
|
||||
for key, p := range existing {
|
||||
if _, ok := wanted[key]; !ok {
|
||||
removedIDs = append(removedIDs, p.ID)
|
||||
}
|
||||
}
|
||||
if err = s.deletePermissions(ctx, tx, removedIDs); err != nil {
|
||||
return API{}, err
|
||||
}
|
||||
|
||||
// Insert new keys and update the display fields of existing ones
|
||||
for key, in := range wanted {
|
||||
if cur, ok := existing[key]; ok {
|
||||
err = tx.WithContext(ctx).
|
||||
Model(&Permission{}).
|
||||
Where("id = ?", cur.ID).
|
||||
Updates(map[string]any{"name": in.Name, "description": in.Description}).
|
||||
Error
|
||||
if err != nil {
|
||||
return API{}, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
newPermission := Permission{
|
||||
APIID: api.ID,
|
||||
Key: in.Key,
|
||||
Name: in.Name,
|
||||
Description: in.Description,
|
||||
}
|
||||
if err = tx.WithContext(ctx).Create(&newPermission).Error; err != nil {
|
||||
return API{}, err
|
||||
}
|
||||
}
|
||||
|
||||
err = tx.WithContext(ctx).
|
||||
Model(&API{}).
|
||||
Where("id = ?", api.ID).
|
||||
Update("updated_at", new(datatype.DateTime(time.Now()))).
|
||||
Error
|
||||
if err != nil {
|
||||
return API{}, err
|
||||
}
|
||||
|
||||
api, err = s.Get(ctx, tx, id)
|
||||
if err != nil {
|
||||
return API{}, err
|
||||
}
|
||||
|
||||
if err = tx.Commit().Error; err != nil {
|
||||
return API{}, err
|
||||
}
|
||||
|
||||
return api, nil
|
||||
}
|
||||
|
||||
// ClientAPIAccess is the set of API permissions granted to a client, split by the subject the resulting tokens act for
|
||||
// User-delegated permissions may be requested on behalf of a signed-in user, client permissions may be obtained by the client itself through the client credentials grant
|
||||
type ClientAPIAccess struct {
|
||||
UserDelegatedPermissionIDs []string
|
||||
ClientPermissionIDs []string
|
||||
}
|
||||
|
||||
// GetClientAPIAccess returns the API permissions a client is allowed to request, split by subject type
|
||||
// Only custom-API permissions are tracked here because the identity scopes are freely requestable by every client
|
||||
func (s *Service) GetClientAPIAccess(ctx context.Context, clientID string) (access ClientAPIAccess, err error) {
|
||||
var rows []OidcClientAllowedAPIPermission
|
||||
err = s.db.WithContext(ctx).
|
||||
Where("oidc_client_id = ?", clientID).
|
||||
Find(&rows).
|
||||
Error
|
||||
if err != nil {
|
||||
return ClientAPIAccess{}, err
|
||||
}
|
||||
|
||||
for _, row := range rows {
|
||||
switch row.SubjectType {
|
||||
case oidc.SubjectTypeClient:
|
||||
access.ClientPermissionIDs = append(access.ClientPermissionIDs, row.APIPermissionID)
|
||||
case oidc.SubjectTypeUser:
|
||||
access.UserDelegatedPermissionIDs = append(access.UserDelegatedPermissionIDs, row.APIPermissionID)
|
||||
default:
|
||||
// Nop - ignore
|
||||
}
|
||||
}
|
||||
|
||||
return access, nil
|
||||
}
|
||||
|
||||
// SetClientAPIAccess replaces the client's API-access grants for both subject types with the given permission IDs
|
||||
// Unknown permission IDs are ignored
|
||||
// It returns the access that was actually applied
|
||||
func (s *Service) SetClientAPIAccess(ctx context.Context, clientID string, access ClientAPIAccess) (applied ClientAPIAccess, err error) {
|
||||
tx := s.db.Begin()
|
||||
defer func() {
|
||||
tx.Rollback()
|
||||
}()
|
||||
|
||||
// Ensure the client exists so callers get a 404 for an unknown client
|
||||
var client model.OidcClient
|
||||
if err = tx.WithContext(ctx).Select("id").Where("id = ?", clientID).First(&client).Error; err != nil {
|
||||
return ClientAPIAccess{}, err
|
||||
}
|
||||
|
||||
applied.UserDelegatedPermissionIDs, err = s.filterAssignablePermissionIDs(ctx, tx, access.UserDelegatedPermissionIDs)
|
||||
if err != nil {
|
||||
return ClientAPIAccess{}, err
|
||||
}
|
||||
applied.ClientPermissionIDs, err = s.filterAssignablePermissionIDs(ctx, tx, access.ClientPermissionIDs)
|
||||
if err != nil {
|
||||
return ClientAPIAccess{}, err
|
||||
}
|
||||
|
||||
// Replace the grants for this client
|
||||
err = tx.WithContext(ctx).
|
||||
Where("oidc_client_id = ?", clientID).
|
||||
Delete(&OidcClientAllowedAPIPermission{}).
|
||||
Error
|
||||
if err != nil {
|
||||
return ClientAPIAccess{}, err
|
||||
}
|
||||
|
||||
rows := make([]OidcClientAllowedAPIPermission, 0, len(applied.UserDelegatedPermissionIDs)+len(applied.ClientPermissionIDs))
|
||||
for _, permissionID := range applied.UserDelegatedPermissionIDs {
|
||||
rows = append(rows, OidcClientAllowedAPIPermission{OidcClientID: clientID, APIPermissionID: permissionID, SubjectType: oidc.SubjectTypeUser})
|
||||
}
|
||||
for _, permissionID := range applied.ClientPermissionIDs {
|
||||
rows = append(rows, OidcClientAllowedAPIPermission{OidcClientID: clientID, APIPermissionID: permissionID, SubjectType: oidc.SubjectTypeClient})
|
||||
}
|
||||
if len(rows) > 0 {
|
||||
if err = tx.WithContext(ctx).Create(&rows).Error; err != nil {
|
||||
return ClientAPIAccess{}, err
|
||||
}
|
||||
}
|
||||
|
||||
if err = tx.Commit().Error; err != nil {
|
||||
return ClientAPIAccess{}, err
|
||||
}
|
||||
|
||||
return applied, nil
|
||||
}
|
||||
|
||||
// ClientAPIScopesAndAudiences returns the permission keys a client may request and the distinct audiences of the custom APIs those permissions belong to, across both subject types
|
||||
// The OIDC module uses this to widen fosite's scope and audience validation for the client; the per-flow subject-type enforcement happens when the resource is resolved
|
||||
func (s *Service) ClientAPIScopesAndAudiences(ctx context.Context, tx *gorm.DB, clientID string) (scopes []string, audiences []string, err error) {
|
||||
if tx == nil {
|
||||
tx = s.db
|
||||
}
|
||||
|
||||
var rows []struct {
|
||||
Key string
|
||||
Audience string
|
||||
}
|
||||
err = tx.WithContext(ctx).
|
||||
Table("oidc_clients_allowed_api_permissions AS g").
|
||||
Select("api_permissions.key AS key, apis.audience AS audience").
|
||||
Joins("JOIN api_permissions ON api_permissions.id = g.api_permission_id").
|
||||
Joins("JOIN apis ON apis.id = api_permissions.api_id").
|
||||
Where("g.oidc_client_id = ?", clientID).
|
||||
Scan(&rows).
|
||||
Error
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
scopeSeen := make(map[string]struct{}, len(rows))
|
||||
audienceSeen := make(map[string]struct{}, len(rows))
|
||||
scopes = make([]string, 0, len(rows))
|
||||
audiences = make([]string, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
if _, ok := scopeSeen[row.Key]; !ok {
|
||||
scopeSeen[row.Key] = struct{}{}
|
||||
scopes = append(scopes, row.Key)
|
||||
}
|
||||
if _, ok := audienceSeen[row.Audience]; !ok {
|
||||
audienceSeen[row.Audience] = struct{}{}
|
||||
audiences = append(audiences, row.Audience)
|
||||
}
|
||||
}
|
||||
|
||||
return scopes, audiences, nil
|
||||
}
|
||||
|
||||
// AllowedScopesForAudience returns the permission keys the client is allowed for the API identified by the given audience and subject type, plus whether such an API exists
|
||||
func (s *Service) AllowedScopesForAudience(ctx context.Context, clientID, audience string, subjectType oidc.SubjectType) (scopes []string, apiExists bool, err error) {
|
||||
var api API
|
||||
err = s.db.WithContext(ctx).
|
||||
Select("id").
|
||||
Where("audience = ?", audience).
|
||||
First(&api).
|
||||
Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
err = s.db.WithContext(ctx).
|
||||
Table("api_permissions").
|
||||
Select("api_permissions.key").
|
||||
Joins("JOIN oidc_clients_allowed_api_permissions g ON g.api_permission_id = api_permissions.id AND g.oidc_client_id = ? AND g.subject_type = ?", clientID, subjectType).
|
||||
Where("api_permissions.api_id = ?", api.ID).
|
||||
Pluck("api_permissions.key", &scopes).
|
||||
Error
|
||||
if err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
|
||||
return scopes, true, nil
|
||||
}
|
||||
|
||||
// DescribePermissions returns the permission rows of the API identified by the given audience whose key is in keys
|
||||
// The consent screen uses these to show friendly names instead of raw scope keys
|
||||
func (s *Service) DescribePermissions(ctx context.Context, audience string, keys []string) ([]Permission, error) {
|
||||
if len(keys) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var permissions []Permission
|
||||
err := s.db.WithContext(ctx).
|
||||
Model(&Permission{}).
|
||||
Joins("JOIN apis ON apis.id = api_permissions.api_id").
|
||||
Where("apis.audience = ? AND api_permissions.key IN ?", audience, keys).
|
||||
Find(&permissions).
|
||||
Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return permissions, nil
|
||||
}
|
||||
|
||||
// filterAssignablePermissionIDs returns the subset of the given permission IDs that exist
|
||||
func (s *Service) filterAssignablePermissionIDs(ctx context.Context, tx *gorm.DB, permissionIDs []string) ([]string, error) {
|
||||
if len(permissionIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var valid []string
|
||||
err := tx.WithContext(ctx).
|
||||
Model(&Permission{}).
|
||||
Where("id IN ?", permissionIDs).
|
||||
Pluck("id", &valid).
|
||||
Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return valid, nil
|
||||
}
|
||||
|
||||
// deletePermissions removes permissions by ID along with any client allow-list grants that reference them
|
||||
// The explicit grant delete keeps this correct even when the database does not enforce ON DELETE CASCADE at runtime
|
||||
func (s *Service) deletePermissions(ctx context.Context, tx *gorm.DB, permissionIDs []string) error {
|
||||
if tx == nil {
|
||||
tx = s.db
|
||||
}
|
||||
if len(permissionIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
err := tx.WithContext(ctx).
|
||||
Where("api_permission_id IN ?", permissionIDs).
|
||||
Delete(&OidcClientAllowedAPIPermission{}).
|
||||
Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.WithContext(ctx).
|
||||
Where("id IN ?", permissionIDs).
|
||||
Delete(&Permission{}).
|
||||
Error
|
||||
}
|
||||
|
||||
func collectIDs(permissions []Permission) []string {
|
||||
ids := make([]string, len(permissions))
|
||||
for i, p := range permissions {
|
||||
ids[i] = p.ID
|
||||
}
|
||||
return ids
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/oidc"
|
||||
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
|
||||
)
|
||||
|
||||
func TestAPICrudAndPermissionDiff(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
svc := New(Dependencies{DB: db}).service
|
||||
|
||||
created, err := svc.Create(t.Context(), apiCreateDto{Name: "Orders API", Resource: "https://api.orders.example.com"})
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, created.ID)
|
||||
|
||||
// The resource is unique.
|
||||
_, err = svc.Create(t.Context(), apiCreateDto{Name: "Dup", Resource: "https://api.orders.example.com"})
|
||||
require.ErrorIs(t, err, &common.AlreadyInUseError{})
|
||||
|
||||
desc := "Read orders"
|
||||
updated, err := svc.UpdatePermissions(t.Context(), created.ID, apiPermissionsUpdateDto{Permissions: []apiPermissionInputDto{
|
||||
{Key: "read:orders", Name: "Read orders", Description: &desc},
|
||||
{Key: "write:orders", Name: "Write orders"},
|
||||
}})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, updated.Permissions, 2)
|
||||
|
||||
// Grant a client the read:orders permission for both subject types, then remove that permission
|
||||
// and confirm the grants are cleaned up while write:orders (and its key) survives.
|
||||
readPerm := findPermission(updated, "read:orders")
|
||||
require.NotNil(t, readPerm)
|
||||
require.NoError(t, db.Create(&model.OidcClient{Base: model.Base{ID: "client-1"}, Name: "Client 1"}).Error)
|
||||
require.NoError(t, db.Create(&OidcClientAllowedAPIPermission{OidcClientID: "client-1", APIPermissionID: readPerm.ID, SubjectType: oidc.SubjectTypeUser}).Error)
|
||||
require.NoError(t, db.Create(&OidcClientAllowedAPIPermission{OidcClientID: "client-1", APIPermissionID: readPerm.ID, SubjectType: oidc.SubjectTypeClient}).Error)
|
||||
|
||||
updated, err = svc.UpdatePermissions(t.Context(), created.ID, apiPermissionsUpdateDto{Permissions: []apiPermissionInputDto{
|
||||
{Key: "write:orders", Name: "Write orders (renamed)"},
|
||||
}})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, updated.Permissions, 1)
|
||||
assert.Equal(t, "write:orders", updated.Permissions[0].Key)
|
||||
assert.Equal(t, "Write orders (renamed)", updated.Permissions[0].Name)
|
||||
|
||||
var grantCount int64
|
||||
require.NoError(t, db.Model(&OidcClientAllowedAPIPermission{}).Where("api_permission_id = ?", readPerm.ID).Count(&grantCount).Error)
|
||||
assert.Equal(t, int64(0), grantCount)
|
||||
|
||||
renamed, err := svc.Update(t.Context(), created.ID, apiUpdateDto{Name: "Orders"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Orders", renamed.Name)
|
||||
require.NotNil(t, renamed.UpdatedAt)
|
||||
|
||||
require.NoError(t, svc.Delete(t.Context(), created.ID))
|
||||
_, err = svc.Get(t.Context(), nil, created.ID)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestClientApiAccessAllowList(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
svc := New(Dependencies{DB: db}).service
|
||||
|
||||
require.NoError(t, db.Create(&model.OidcClient{Base: model.Base{ID: "client-1"}, Name: "Client 1"}).Error)
|
||||
|
||||
orders, err := svc.Create(t.Context(), apiCreateDto{Name: "Orders", Resource: "https://api.orders.example.com"})
|
||||
require.NoError(t, err)
|
||||
orders, err = svc.UpdatePermissions(t.Context(), orders.ID, apiPermissionsUpdateDto{Permissions: []apiPermissionInputDto{
|
||||
{Key: "read:orders", Name: "Read"},
|
||||
{Key: "write:orders", Name: "Write"},
|
||||
}})
|
||||
require.NoError(t, err)
|
||||
readID := findPermission(orders, "read:orders").ID
|
||||
writeID := findPermission(orders, "write:orders").ID
|
||||
|
||||
// Unknown IDs are filtered out, and the subject types are stored independently.
|
||||
applied, err := svc.SetClientAPIAccess(t.Context(), "client-1", ClientAPIAccess{
|
||||
UserDelegatedPermissionIDs: []string{readID, "does-not-exist"},
|
||||
ClientPermissionIDs: []string{writeID, "does-not-exist"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.ElementsMatch(t, []string{readID}, applied.UserDelegatedPermissionIDs)
|
||||
assert.ElementsMatch(t, []string{writeID}, applied.ClientPermissionIDs)
|
||||
|
||||
got, err := svc.GetClientAPIAccess(t.Context(), "client-1")
|
||||
require.NoError(t, err)
|
||||
assert.ElementsMatch(t, []string{readID}, got.UserDelegatedPermissionIDs)
|
||||
assert.ElementsMatch(t, []string{writeID}, got.ClientPermissionIDs)
|
||||
|
||||
// The same permission can be granted for both subject types, and both sets are fully replaced on each call.
|
||||
_, err = svc.SetClientAPIAccess(t.Context(), "client-1", ClientAPIAccess{
|
||||
UserDelegatedPermissionIDs: []string{readID, writeID},
|
||||
ClientPermissionIDs: []string{readID},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
got, err = svc.GetClientAPIAccess(t.Context(), "client-1")
|
||||
require.NoError(t, err)
|
||||
assert.ElementsMatch(t, []string{readID, writeID}, got.UserDelegatedPermissionIDs)
|
||||
assert.ElementsMatch(t, []string{readID}, got.ClientPermissionIDs)
|
||||
|
||||
// Clearing one subject type leaves the other untouched.
|
||||
_, err = svc.SetClientAPIAccess(t.Context(), "client-1", ClientAPIAccess{ClientPermissionIDs: []string{readID}})
|
||||
require.NoError(t, err)
|
||||
got, err = svc.GetClientAPIAccess(t.Context(), "client-1")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, got.UserDelegatedPermissionIDs)
|
||||
assert.ElementsMatch(t, []string{readID}, got.ClientPermissionIDs)
|
||||
|
||||
// Clearing everything.
|
||||
_, err = svc.SetClientAPIAccess(t.Context(), "client-1", ClientAPIAccess{})
|
||||
require.NoError(t, err)
|
||||
got, err = svc.GetClientAPIAccess(t.Context(), "client-1")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, got.UserDelegatedPermissionIDs)
|
||||
assert.Empty(t, got.ClientPermissionIDs)
|
||||
|
||||
// An unknown client is rejected (surfaces as 404 at the HTTP layer).
|
||||
_, err = svc.SetClientAPIAccess(t.Context(), "nope", ClientAPIAccess{UserDelegatedPermissionIDs: []string{readID}})
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
// TestAllowedScopesForAudienceFiltersBySubjectType guards that the scopes resolved for a flow
|
||||
// only come from the grants of that flow's subject type.
|
||||
func TestAllowedScopesForAudienceFiltersBySubjectType(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
svc := New(Dependencies{DB: db}).service
|
||||
|
||||
require.NoError(t, db.Create(&model.OidcClient{Base: model.Base{ID: "client-1"}, Name: "Client 1"}).Error)
|
||||
|
||||
orders, err := svc.Create(t.Context(), apiCreateDto{Name: "Orders", Resource: "https://api.orders.example.com"})
|
||||
require.NoError(t, err)
|
||||
orders, err = svc.UpdatePermissions(t.Context(), orders.ID, apiPermissionsUpdateDto{Permissions: []apiPermissionInputDto{
|
||||
{Key: "read:orders", Name: "Read"},
|
||||
{Key: "write:orders", Name: "Write"},
|
||||
}})
|
||||
require.NoError(t, err)
|
||||
readID := findPermission(orders, "read:orders").ID
|
||||
writeID := findPermission(orders, "write:orders").ID
|
||||
|
||||
_, err = svc.SetClientAPIAccess(t.Context(), "client-1", ClientAPIAccess{
|
||||
UserDelegatedPermissionIDs: []string{readID},
|
||||
ClientPermissionIDs: []string{writeID},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
userScopes, exists, err := svc.AllowedScopesForAudience(t.Context(), "client-1", "https://api.orders.example.com", oidc.SubjectTypeUser)
|
||||
require.NoError(t, err)
|
||||
require.True(t, exists)
|
||||
assert.ElementsMatch(t, []string{"read:orders"}, userScopes)
|
||||
|
||||
clientScopes, exists, err := svc.AllowedScopesForAudience(t.Context(), "client-1", "https://api.orders.example.com", oidc.SubjectTypeClient)
|
||||
require.NoError(t, err)
|
||||
require.True(t, exists)
|
||||
assert.ElementsMatch(t, []string{"write:orders"}, clientScopes)
|
||||
|
||||
// The fosite widening still sees the union of both subject types.
|
||||
scopes, audiences, err := svc.ClientAPIScopesAndAudiences(t.Context(), nil, "client-1")
|
||||
require.NoError(t, err)
|
||||
assert.ElementsMatch(t, []string{"read:orders", "write:orders"}, scopes)
|
||||
assert.ElementsMatch(t, []string{"https://api.orders.example.com"}, audiences)
|
||||
}
|
||||
|
||||
func TestUpdatePermissionsRejectsReservedKeys(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
svc := New(Dependencies{DB: db}).service
|
||||
|
||||
orders, err := svc.Create(t.Context(), apiCreateDto{Name: "Orders", Resource: "https://api.orders.example.com"})
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, key := range []string{"openid", "profile", "email", "email_verified", "groups", "offline_access", "Email"} {
|
||||
_, err := svc.UpdatePermissions(t.Context(), orders.ID, apiPermissionsUpdateDto{Permissions: []apiPermissionInputDto{
|
||||
{Key: key, Name: "Reserved"},
|
||||
}})
|
||||
require.Error(t, err, "key %q must be rejected", key)
|
||||
var validationErr *common.ValidationError
|
||||
require.ErrorAs(t, err, &validationErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdatePermissionsRejectsDuplicateKeys(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
svc := New(Dependencies{DB: db}).service
|
||||
|
||||
orders, err := svc.Create(t.Context(), apiCreateDto{Name: "Orders", Resource: "https://api.orders.example.com"})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Two rows with the same key must be rejected rather than silently coalesced last-wins
|
||||
_, err = svc.UpdatePermissions(t.Context(), orders.ID, apiPermissionsUpdateDto{Permissions: []apiPermissionInputDto{
|
||||
{Key: "read:orders", Name: "Read"},
|
||||
{Key: "read:orders", Name: "Read again"},
|
||||
}})
|
||||
require.Error(t, err)
|
||||
var validationErr *common.ValidationError
|
||||
require.ErrorAs(t, err, &validationErr)
|
||||
}
|
||||
|
||||
func TestUpdatePermissionsRejectsInvalidKeyCharacters(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
svc := New(Dependencies{DB: db}).service
|
||||
|
||||
orders, err := svc.Create(t.Context(), apiCreateDto{Name: "Orders", Resource: "https://api.orders.example.com"})
|
||||
require.NoError(t, err)
|
||||
|
||||
// A space corrupts the space-delimited scope claim, and the unit separator is the consent delimiter
|
||||
for _, key := range []string{"read orders", "read\x1forders", "read\"orders", "bad\\key", "tab\tkey"} {
|
||||
_, err := svc.UpdatePermissions(t.Context(), orders.ID, apiPermissionsUpdateDto{Permissions: []apiPermissionInputDto{
|
||||
{Key: key, Name: "Invalid"},
|
||||
}})
|
||||
require.Error(t, err, "key %q must be rejected", key)
|
||||
var validationErr *common.ValidationError
|
||||
require.ErrorAs(t, err, &validationErr)
|
||||
}
|
||||
|
||||
// A valid scope-token key is accepted
|
||||
_, err = svc.UpdatePermissions(t.Context(), orders.ID, apiPermissionsUpdateDto{Permissions: []apiPermissionInputDto{
|
||||
{Key: "read:orders", Name: "Read"},
|
||||
}})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestCreateRejectsIssuerResource(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
const issuer = "https://id.example.com"
|
||||
svc := New(Dependencies{DB: db, Issuer: issuer}).service
|
||||
|
||||
// The issuer itself, a trailing-slash variant, and a different-cased variant are all reserved
|
||||
for _, resource := range []string{issuer, issuer + "/", "https://ID.example.com"} {
|
||||
_, err := svc.Create(t.Context(), apiCreateDto{Name: "Reserved", Resource: resource})
|
||||
require.Error(t, err, "resource %q must be rejected", resource)
|
||||
var validationErr *common.ValidationError
|
||||
require.ErrorAs(t, err, &validationErr)
|
||||
}
|
||||
|
||||
// A normal resource is accepted
|
||||
_, err := svc.Create(t.Context(), apiCreateDto{Name: "Orders", Resource: "https://api.orders.example.com"})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestCreateAcceptsAbsoluteResourceURIs(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
svc := New(Dependencies{DB: db}).service
|
||||
|
||||
for _, resource := range []string{"https://api.orders.example.com", "api://PocketID", "urn:my-app"} {
|
||||
_, err := svc.Create(t.Context(), apiCreateDto{Name: "Orders", Resource: resource})
|
||||
require.NoError(t, err, "resource %q must be accepted", resource)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDescribePermissions(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
svc := New(Dependencies{DB: db}).service
|
||||
|
||||
orders, err := svc.Create(t.Context(), apiCreateDto{Name: "Orders", Resource: "https://api.orders.example.com"})
|
||||
require.NoError(t, err)
|
||||
desc := "Read orders"
|
||||
_, err = svc.UpdatePermissions(t.Context(), orders.ID, apiPermissionsUpdateDto{Permissions: []apiPermissionInputDto{
|
||||
{Key: "read:orders", Name: "Read orders", Description: &desc},
|
||||
{Key: "write:orders", Name: "Write orders"},
|
||||
}})
|
||||
require.NoError(t, err)
|
||||
|
||||
infos, err := svc.DescribePermissions(t.Context(), "https://api.orders.example.com", []string{"read:orders", "unknown"})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, infos, 1)
|
||||
assert.Equal(t, "read:orders", infos[0].Key)
|
||||
assert.Equal(t, "Read orders", infos[0].Name)
|
||||
require.NotNil(t, infos[0].Description)
|
||||
assert.Equal(t, "Read orders", *infos[0].Description)
|
||||
}
|
||||
|
||||
func findPermission(api API, key string) *Permission {
|
||||
for i := range api.Permissions {
|
||||
if api.Permissions[i].Key == key {
|
||||
return &api.Permissions[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user