mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-08-31 08:11:27 +02:00
feat: allow admins to auto grant APIs to CIMD clients (#1692)
This commit is contained in:
@@ -6,18 +6,20 @@ import (
|
||||
|
||||
// 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"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Resource string `json:"resource" copier:"Audience"`
|
||||
CreatedAt datatype.DateTime `json:"createdAt"`
|
||||
Permissions []apiPermissionResponseDto `json:"permissions"`
|
||||
AllowCIMDClients bool `json:"allowCimdClients"`
|
||||
}
|
||||
|
||||
type apiPermissionResponseDto struct {
|
||||
ID string `json:"id"`
|
||||
Key string `json:"key"`
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
ID string `json:"id"`
|
||||
Key string `json:"key"`
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
AllowedForCIMDClients bool `json:"allowedForCimdClients"`
|
||||
}
|
||||
|
||||
// apiCreateDto is the payload for creating an API
|
||||
@@ -44,14 +46,51 @@ 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 {
|
||||
// apiCimdAccessUpdateDto replaces which permissions of an API every CIMD client may request
|
||||
// The permission IDs are kept while Enabled is false, so switching access off and on again preserves the selection
|
||||
type apiCimdAccessUpdateDto struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
PermissionIDs []string `json:"permissionIds" binding:"omitempty,dive,required"`
|
||||
}
|
||||
|
||||
// apiClientGrantDto is what one client may do with a single API
|
||||
type apiClientGrantDto struct {
|
||||
UserDelegatedAccess bool `json:"userDelegatedAccess"`
|
||||
ClientAccess bool `json:"clientAccess"`
|
||||
UserDelegatedPermissionIDs []string `json:"userDelegatedPermissionIds"`
|
||||
ClientPermissionIDs []string `json:"clientPermissionIds"`
|
||||
}
|
||||
|
||||
type clientApiAccessUpdateDto struct {
|
||||
type apiClientGrantUpdateDto struct {
|
||||
UserDelegatedAccess bool `json:"userDelegatedAccess"`
|
||||
ClientAccess bool `json:"clientAccess"`
|
||||
UserDelegatedPermissionIDs []string `json:"userDelegatedPermissionIds" binding:"omitempty,dive,required"`
|
||||
ClientPermissionIDs []string `json:"clientPermissionIds" binding:"omitempty,dive,required"`
|
||||
}
|
||||
|
||||
// apiClientAccessDto is one client's grants on a single API, as listed on the API's detail page
|
||||
// The CIMD fields are read-only here: that access is managed on the API, not per client
|
||||
type apiClientAccessDto struct {
|
||||
Client apiClientDto `json:"client"`
|
||||
apiClientGrantDto
|
||||
CIMDGrantedAccess bool `json:"cimdGrantedAccess"`
|
||||
CIMDGrantedPermissionIDs []string `json:"cimdGrantedPermissionIds"`
|
||||
}
|
||||
|
||||
// clientApiGrantDto is one API a client may reach, as listed on the client's detail page
|
||||
type clientApiGrantDto struct {
|
||||
API apiResponseDto `json:"api"`
|
||||
apiClientGrantDto
|
||||
CIMDGrantedAccess bool `json:"cimdGrantedAccess"`
|
||||
CIMDGrantedPermissionIDs []string `json:"cimdGrantedPermissionIds"`
|
||||
}
|
||||
|
||||
// apiClientDto identifies an OIDC client with the few fields the API detail page needs to render a row
|
||||
type apiClientDto struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ClientType string `json:"clientType"`
|
||||
IsPublic bool `json:"isPublic"`
|
||||
HasLogo bool `json:"hasLogo"`
|
||||
HasDarkLogo bool `json:"hasDarkLogo"`
|
||||
}
|
||||
|
||||
@@ -39,14 +39,9 @@ func (h *handler) list(c *gin.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
items := make([]apiResponseDto, len(apis))
|
||||
for i, api := range apis {
|
||||
var item apiResponseDto
|
||||
if err := dto.MapStruct(api, &item); err != nil {
|
||||
return err
|
||||
}
|
||||
item.Resource = api.Audience
|
||||
items[i] = item
|
||||
var items []apiResponseDto
|
||||
if err := dto.MapStructList(apis, &items); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, dto.Paginated[apiResponseDto]{
|
||||
@@ -70,7 +65,13 @@ func (h *handler) get(c *gin.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
return h.respond(c, http.StatusOK, api)
|
||||
var responseDto apiResponseDto
|
||||
if err := dto.MapStruct(api, &responseDto); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, responseDto)
|
||||
return nil
|
||||
}
|
||||
|
||||
// create godoc
|
||||
@@ -93,7 +94,13 @@ func (h *handler) create(c *gin.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
return h.respond(c, http.StatusCreated, api)
|
||||
var responseDto apiResponseDto
|
||||
if err := dto.MapStruct(api, &responseDto); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, responseDto)
|
||||
return nil
|
||||
}
|
||||
|
||||
// update godoc
|
||||
@@ -117,7 +124,13 @@ func (h *handler) update(c *gin.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
return h.respond(c, http.StatusOK, api)
|
||||
var responseDto apiResponseDto
|
||||
if err := dto.MapStruct(api, &responseDto); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, responseDto)
|
||||
return nil
|
||||
}
|
||||
|
||||
// delete godoc
|
||||
@@ -157,71 +170,207 @@ func (h *handler) updatePermissions(c *gin.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
return h.respond(c, http.StatusOK, api)
|
||||
var responseDto apiResponseDto
|
||||
if err := dto.MapStruct(api, &responseDto); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, responseDto)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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
|
||||
// updateCimdAccess godoc
|
||||
// @Summary Update metadata document client access
|
||||
// @Description Replace which permissions of an API every client registered through a Client ID Metadata Document may request
|
||||
// @Tags APIs
|
||||
// @Accept json
|
||||
// @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) error {
|
||||
access, err := h.service.GetClientAPIAccess(c.Request.Context(), c.Param("clientId"))
|
||||
// @Param id path string true "API ID"
|
||||
// @Param access body apiCimdAccessUpdateDto true "Metadata document client access"
|
||||
// @Success 200 {object} apiResponseDto "Updated API"
|
||||
// @Router /api/apis/{id}/cimd-access [put]
|
||||
func (h *handler) updateCimdAccess(c *gin.Context) error {
|
||||
var input apiCimdAccessUpdateDto
|
||||
if err := httpserver.BindJSON(c, &input); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
api, err := h.service.SetCIMDAccess(c.Request.Context(), c.Param("id"), input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, newClientApiAccessDto(access))
|
||||
var responseDto apiResponseDto
|
||||
if err := dto.MapStruct(api, &responseDto); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, responseDto)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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
|
||||
// listClients godoc
|
||||
// @Summary List clients with access to an API
|
||||
// @Description Get a paginated list of OIDC clients that may reach the API, with their permissions split into user-delegated and client (machine-to-machine) access
|
||||
// @Tags APIs
|
||||
// @Produce json
|
||||
// @Param id path string true "API ID"
|
||||
// @Param search query string false "Search term to filter clients by name"
|
||||
// @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[apiClientAccessDto]
|
||||
// @Router /api/apis/{id}/clients [get]
|
||||
func (h *handler) listClients(c *gin.Context) error {
|
||||
clients, pagination, err := h.service.ListAPIClients(c.Request.Context(), c.Param("id"), c.Query("search"), utils.ParseListRequestOptions(c))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var items []apiClientAccessDto
|
||||
if err := dto.MapStructList(clients, &items); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, dto.Paginated[apiClientAccessDto]{
|
||||
Data: items,
|
||||
Pagination: pagination,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// listAssignableClients godoc
|
||||
// @Summary List clients that can still be granted access to an API
|
||||
// @Description Get a paginated list of OIDC clients that have no grant on the API yet
|
||||
// @Tags APIs
|
||||
// @Produce json
|
||||
// @Param id path string true "API ID"
|
||||
// @Param search query string false "Search term to filter clients by name"
|
||||
// @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[apiClientDto]
|
||||
// @Router /api/apis/{id}/assignable-clients [get]
|
||||
func (h *handler) listAssignableClients(c *gin.Context) error {
|
||||
clients, pagination, err := h.service.ListAssignableClients(c.Request.Context(), c.Param("id"), c.Query("search"), utils.ParseListRequestOptions(c))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var items []apiClientDto
|
||||
if err := dto.MapStructList(clients, &items); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, dto.Paginated[apiClientDto]{
|
||||
Data: items,
|
||||
Pagination: pagination,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// listAssignableApis godoc
|
||||
// @Summary List APIs a client can still be granted access to
|
||||
// @Description Get a paginated list of APIs the OIDC client cannot already reach
|
||||
// @Tags APIs
|
||||
// @Produce json
|
||||
// @Param clientId path string true "OIDC Client ID"
|
||||
// @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[apiResponseDto]
|
||||
// @Router /api/api-access/{clientId}/assignable-apis [get]
|
||||
func (h *handler) listAssignableApis(c *gin.Context) error {
|
||||
apis, pagination, err := h.service.ListAssignableAPIs(c.Request.Context(), c.Param("clientId"), c.Query("search"), utils.ParseListRequestOptions(c))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var items []apiResponseDto
|
||||
if err := dto.MapStructList(apis, &items); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, dto.Paginated[apiResponseDto]{
|
||||
Data: items,
|
||||
Pagination: pagination,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateClientAccessForApi godoc
|
||||
// @Summary Update a client's access to an API
|
||||
// @Description Replace the permissions of this API a single OIDC client may request, leaving its grants on other APIs untouched
|
||||
// @Tags APIs
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "API ID"
|
||||
// @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) error {
|
||||
var input clientApiAccessUpdateDto
|
||||
// @Param access body apiClientGrantUpdateDto true "Access and allowed permission IDs per subject type"
|
||||
// @Success 200 {object} apiClientGrantDto
|
||||
// @Router /api/apis/{id}/clients/{clientId} [put]
|
||||
func (h *handler) updateClientAccessForApi(c *gin.Context) error {
|
||||
var input apiClientGrantUpdateDto
|
||||
err := httpserver.BindJSON(c, &input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
applied, err := h.service.SetClientAPIAccess(c.Request.Context(), c.Param("clientId"), ClientAPIAccess(input))
|
||||
applied, err := h.service.SetAPIClientAccess(c.Request.Context(), c.Param("id"), c.Param("clientId"), APIClientGrant(input))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, newClientApiAccessDto(applied))
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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) error {
|
||||
var responseDto apiResponseDto
|
||||
if err := dto.MapStruct(api, &responseDto); err != nil {
|
||||
var grant apiClientGrantDto
|
||||
if err := dto.MapStruct(applied, &grant); err != nil {
|
||||
return err
|
||||
}
|
||||
responseDto.Resource = api.Audience
|
||||
c.JSON(status, responseDto)
|
||||
|
||||
c.JSON(http.StatusOK, grant)
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeClientAccessForApi godoc
|
||||
// @Summary Revoke a client's access to an API
|
||||
// @Description Remove every permission of this API a single OIDC client was allowed to request
|
||||
// @Tags APIs
|
||||
// @Param id path string true "API ID"
|
||||
// @Param clientId path string true "OIDC Client ID"
|
||||
// @Success 204 "No Content"
|
||||
// @Router /api/apis/{id}/clients/{clientId} [delete]
|
||||
func (h *handler) removeClientAccessForApi(c *gin.Context) error {
|
||||
err := h.service.RemoveAPIClientAccess(c.Request.Context(), c.Param("id"), c.Param("clientId"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
return nil
|
||||
}
|
||||
|
||||
// listClientApis godoc
|
||||
// @Summary List APIs a client may access
|
||||
// @Description Get every API the OIDC client may request tokens for, with its access and permissions split into user-delegated and client (machine-to-machine) access
|
||||
// @Tags APIs
|
||||
// @Produce json
|
||||
// @Param clientId path string true "OIDC Client ID"
|
||||
// @Success 200 {array} clientApiGrantDto
|
||||
// @Router /api/api-access/{clientId}/apis [get]
|
||||
func (h *handler) listClientApis(c *gin.Context) error {
|
||||
grants, err := h.service.ListClientAPIs(c.Request.Context(), c.Param("clientId"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var items []clientApiGrantDto
|
||||
if err := dto.MapStructList(grants, &items); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, items)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -9,9 +9,10 @@ import (
|
||||
type API struct {
|
||||
model.Base
|
||||
|
||||
Name string `sortable:"true"`
|
||||
Audience string `sortable:"true"`
|
||||
UpdatedAt *datatype.DateTime
|
||||
Name string `sortable:"true"`
|
||||
Audience string `sortable:"true"`
|
||||
UpdatedAt *datatype.DateTime
|
||||
AllowCIMDClients bool `gorm:"column:allow_cimd_clients"`
|
||||
|
||||
Permissions []Permission `gorm:"foreignKey:APIID;references:ID;constraint:OnDelete:CASCADE"`
|
||||
}
|
||||
@@ -19,14 +20,25 @@ type API struct {
|
||||
type Permission struct {
|
||||
model.Base
|
||||
|
||||
APIID string `gorm:"column:api_id"`
|
||||
Key string `sortable:"true"`
|
||||
Name string
|
||||
Description *string
|
||||
APIID string `gorm:"column:api_id"`
|
||||
Key string `sortable:"true"`
|
||||
Name string
|
||||
Description *string
|
||||
AllowedForCIMDClients bool `gorm:"column:allowed_for_cimd_clients"`
|
||||
}
|
||||
|
||||
func (Permission) TableName() string { return "api_permissions" }
|
||||
|
||||
type OidcClientAllowedAPI struct {
|
||||
OidcClientID string
|
||||
APIID string `gorm:"column:api_id"`
|
||||
SubjectType oidc.SubjectType
|
||||
}
|
||||
|
||||
func (OidcClientAllowedAPI) TableName() string {
|
||||
return "oidc_clients_allowed_apis"
|
||||
}
|
||||
|
||||
type OidcClientAllowedAPIPermission struct {
|
||||
OidcClientID string
|
||||
APIPermissionID string
|
||||
|
||||
@@ -31,12 +31,12 @@ func New(deps Dependencies) *Module {
|
||||
}
|
||||
|
||||
// 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)
|
||||
func (m *Module) ClientAPIScopes(ctx context.Context, tx *gorm.DB, clientID string, isCIMDClient bool) (scopes []string, audiences []string, err error) {
|
||||
return m.service.ClientAPIScopesAndAudiences(ctx, tx, clientID, isCIMDClient)
|
||||
}
|
||||
|
||||
// AllowedScopesForAudience implements the OIDC module's APIAccessProvider interface
|
||||
func (m *Module) AllowedScopesForAudience(ctx context.Context, tx *gorm.DB, clientID, audience string, subjectType oidc.SubjectType) (scopes []string, apiExists bool, err error) {
|
||||
func (m *Module) AllowedScopesForAudience(ctx context.Context, tx *gorm.DB, clientID, audience string, subjectType oidc.SubjectType) (scopes []string, apiExists bool, hasAccess bool, err error) {
|
||||
return m.service.AllowedScopesForAudience(ctx, tx, clientID, audience, subjectType)
|
||||
}
|
||||
|
||||
@@ -70,10 +70,16 @@ func (m *Module) RegisterRoutes(apiGroup *gin.RouterGroup, adminAuth gin.Handler
|
||||
apis.PUT("/:id", httpserver.Handle(m.handler.update))
|
||||
apis.DELETE("/:id", httpserver.Handle(m.handler.delete))
|
||||
apis.PUT("/:id/permissions", httpserver.Handle(m.handler.updatePermissions))
|
||||
apis.PUT("/:id/cimd-access", httpserver.Handle(m.handler.updateCimdAccess))
|
||||
|
||||
// The same client grants are editable from either side of the relation, so the API can list and manage its clients too
|
||||
apis.GET("/:id/clients", httpserver.Handle(m.handler.listClients))
|
||||
apis.GET("/:id/assignable-clients", httpserver.Handle(m.handler.listAssignableClients))
|
||||
apis.PUT("/:id/clients/:clientId", httpserver.Handle(m.handler.updateClientAccessForApi))
|
||||
apis.DELETE("/:id/clients/:clientId", httpserver.Handle(m.handler.removeClientAccessForApi))
|
||||
|
||||
// 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", httpserver.Handle(m.handler.getClientAccess))
|
||||
access.PUT("/:clientId", httpserver.Handle(m.handler.updateClientAccess))
|
||||
access.GET("/:clientId/apis", httpserver.Handle(m.handler.listClientApis))
|
||||
access.GET("/:clientId/assignable-apis", httpserver.Handle(m.handler.listAssignableApis))
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -258,173 +260,650 @@ func (s *Service) UpdatePermissions(ctx context.Context, id string, input apiPer
|
||||
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
|
||||
// SetCIMDAccess toggles whether an API is open to CIMD clients and stores which permissions they may request
|
||||
// The selection is kept while access is off, so re-enabling restores the previous choice
|
||||
func (s *Service) SetCIMDAccess(ctx context.Context, id string, input apiCimdAccessUpdateDto) (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
|
||||
}
|
||||
|
||||
// Keep only permission IDs that belong to this API; ignore anything else
|
||||
selected := make([]string, 0, len(input.PermissionIDs))
|
||||
for _, permission := range api.Permissions {
|
||||
if slices.Contains(input.PermissionIDs, permission.ID) {
|
||||
selected = append(selected, permission.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// Clear the flag on all permissions first, then set it on the selected ones
|
||||
err = tx.WithContext(ctx).
|
||||
Model(&Permission{}).
|
||||
Where("api_id = ?", api.ID).
|
||||
Update("allowed_for_cimd_clients", false).
|
||||
Error
|
||||
if err != nil {
|
||||
return API{}, err
|
||||
}
|
||||
|
||||
if len(selected) > 0 {
|
||||
err = tx.WithContext(ctx).
|
||||
Model(&Permission{}).
|
||||
Where("api_id = ? AND id IN ?", api.ID, selected).
|
||||
Update("allowed_for_cimd_clients", true).
|
||||
Error
|
||||
if err != nil {
|
||||
return API{}, err
|
||||
}
|
||||
}
|
||||
|
||||
err = tx.WithContext(ctx).
|
||||
Model(&API{}).
|
||||
Where("id = ?", api.ID).
|
||||
Updates(map[string]any{"allow_cimd_clients": input.Enabled, "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 what a client is allowed to do with the custom APIs, split by the subject the resulting tokens act for
|
||||
type ClientAPIAccess struct {
|
||||
UserDelegatedAPIIDs []string
|
||||
ClientAPIIDs []string
|
||||
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) {
|
||||
// ClientAPIGrant is one API a client may reach, together with the grants that apply to it
|
||||
type ClientAPIGrant struct {
|
||||
API API
|
||||
APIClientGrant
|
||||
CIMDGrantedAccess bool
|
||||
CIMDGrantedPermissionIDs []string
|
||||
}
|
||||
|
||||
// ListClientAPIs returns every API the client may request tokens for, ordered by name, including APIs reached only through a CIMD opt-in
|
||||
func (s *Service) ListClientAPIs(ctx context.Context, clientID string) ([]ClientAPIGrant, error) {
|
||||
tx := s.db.Begin()
|
||||
defer func() {
|
||||
tx.Rollback()
|
||||
}()
|
||||
|
||||
if err := ensureOIDCClientExists(ctx, tx, clientID); err != nil {
|
||||
return ClientAPIAccess{}, err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var rows []OidcClientAllowedAPIPermission
|
||||
err = tx.WithContext(ctx).
|
||||
grants := make(map[string]*ClientAPIGrant)
|
||||
grantFor := func(apiID string) *ClientAPIGrant {
|
||||
entry, ok := grants[apiID]
|
||||
if !ok {
|
||||
entry = &ClientAPIGrant{}
|
||||
grants[apiID] = entry
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
// Get the APIs the client may reach
|
||||
var apiRows []OidcClientAllowedAPI
|
||||
err := tx.WithContext(ctx).
|
||||
Where("oidc_client_id = ?", clientID).
|
||||
Find(&rows).
|
||||
Find(&apiRows).
|
||||
Error
|
||||
if err != nil {
|
||||
return ClientAPIAccess{}, err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, row := range rows {
|
||||
// Collect the access flags for each API
|
||||
for _, row := range apiRows {
|
||||
switch row.SubjectType {
|
||||
case oidc.SubjectTypeClient:
|
||||
access.ClientPermissionIDs = append(access.ClientPermissionIDs, row.APIPermissionID)
|
||||
grantFor(row.APIID).ClientAccess = true
|
||||
case oidc.SubjectTypeUser:
|
||||
access.UserDelegatedPermissionIDs = append(access.UserDelegatedPermissionIDs, row.APIPermissionID)
|
||||
default:
|
||||
// Nop - ignore
|
||||
grantFor(row.APIID).UserDelegatedAccess = true
|
||||
}
|
||||
}
|
||||
|
||||
return access, nil
|
||||
var permissionRows []struct {
|
||||
APIID string
|
||||
APIPermissionID string
|
||||
SubjectType oidc.SubjectType
|
||||
}
|
||||
|
||||
// Get the permissions the client may request
|
||||
err = tx.WithContext(ctx).
|
||||
Table("oidc_clients_allowed_api_permissions AS g").
|
||||
Select("api_permissions.api_id AS api_id, g.api_permission_id AS api_permission_id, g.subject_type AS subject_type").
|
||||
Joins("JOIN api_permissions ON api_permissions.id = g.api_permission_id").
|
||||
Where("g.oidc_client_id = ?", clientID).
|
||||
Scan(&permissionRows).
|
||||
Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Collect the permission IDs for each API and subject type
|
||||
for _, row := range permissionRows {
|
||||
entry := grantFor(row.APIID)
|
||||
switch row.SubjectType {
|
||||
case oidc.SubjectTypeClient:
|
||||
entry.ClientPermissionIDs = append(entry.ClientPermissionIDs, row.APIPermissionID)
|
||||
case oidc.SubjectTypeUser:
|
||||
entry.UserDelegatedPermissionIDs = append(entry.UserDelegatedPermissionIDs, row.APIPermissionID)
|
||||
}
|
||||
}
|
||||
|
||||
// Now the same again for the CIMD
|
||||
cimdGranted, err := s.cimdGrantedAccess(ctx, tx, clientID, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, api := range cimdGranted.APIs {
|
||||
grantFor(api.ID).CIMDGrantedAccess = true
|
||||
}
|
||||
for _, permission := range cimdGranted.Permissions {
|
||||
entry, ok := grants[permission.APIID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
entry.CIMDGrantedPermissionIDs = append(entry.CIMDGrantedPermissionIDs, permission.ID)
|
||||
}
|
||||
|
||||
if len(grants) == 0 {
|
||||
return []ClientAPIGrant{}, nil
|
||||
}
|
||||
|
||||
// Load the API rows for the grants we collected, so the caller can see the API's name and audience
|
||||
var apis []API
|
||||
err = tx.WithContext(ctx).
|
||||
Preload("Permissions").
|
||||
Where("id IN ?", slices.Collect(maps.Keys(grants))).
|
||||
Order("name").
|
||||
Find(&apis).
|
||||
Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make([]ClientAPIGrant, 0, len(apis))
|
||||
for _, api := range apis {
|
||||
entry := *grants[api.ID]
|
||||
entry.API = api
|
||||
entry.UserDelegatedPermissionIDs = orEmptyIDs(entry.UserDelegatedPermissionIDs)
|
||||
entry.ClientPermissionIDs = orEmptyIDs(entry.ClientPermissionIDs)
|
||||
entry.CIMDGrantedPermissionIDs = orEmptyIDs(entry.CIMDGrantedPermissionIDs)
|
||||
result = append(result, entry)
|
||||
}
|
||||
|
||||
return result, 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) {
|
||||
// insertClientGrants writes the API and permission grants of one client for both subject types
|
||||
func insertClientGrants(ctx context.Context, tx *gorm.DB, clientID string, access ClientAPIAccess) error {
|
||||
apiRows := make([]OidcClientAllowedAPI, 0, len(access.UserDelegatedAPIIDs)+len(access.ClientAPIIDs))
|
||||
for _, apiID := range access.UserDelegatedAPIIDs {
|
||||
apiRows = append(apiRows, OidcClientAllowedAPI{OidcClientID: clientID, APIID: apiID, SubjectType: oidc.SubjectTypeUser})
|
||||
}
|
||||
for _, apiID := range access.ClientAPIIDs {
|
||||
apiRows = append(apiRows, OidcClientAllowedAPI{OidcClientID: clientID, APIID: apiID, SubjectType: oidc.SubjectTypeClient})
|
||||
}
|
||||
if len(apiRows) > 0 {
|
||||
if err := tx.WithContext(ctx).Create(&apiRows).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
permissionRows := make([]OidcClientAllowedAPIPermission, 0, len(access.UserDelegatedPermissionIDs)+len(access.ClientPermissionIDs))
|
||||
for _, permissionID := range access.UserDelegatedPermissionIDs {
|
||||
permissionRows = append(permissionRows, OidcClientAllowedAPIPermission{OidcClientID: clientID, APIPermissionID: permissionID, SubjectType: oidc.SubjectTypeUser})
|
||||
}
|
||||
for _, permissionID := range access.ClientPermissionIDs {
|
||||
permissionRows = append(permissionRows, OidcClientAllowedAPIPermission{OidcClientID: clientID, APIPermissionID: permissionID, SubjectType: oidc.SubjectTypeClient})
|
||||
}
|
||||
if len(permissionRows) > 0 {
|
||||
if err := tx.WithContext(ctx).Create(&permissionRows).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// APIClientGrant is what one client may do with a single API
|
||||
// Access and permissions are separate: access without any permission yields a token that carries no scope
|
||||
type APIClientGrant struct {
|
||||
UserDelegatedAccess bool
|
||||
ClientAccess bool
|
||||
UserDelegatedPermissionIDs []string
|
||||
ClientPermissionIDs []string
|
||||
}
|
||||
|
||||
// APIClientAccess is a single client's grants on one API, as shown on the API's detail page
|
||||
// The CIMD fields are read-only here: that access is managed on the API, not per client
|
||||
type APIClientAccess struct {
|
||||
Client model.OidcClient
|
||||
APIClientGrant
|
||||
CIMDGrantedAccess bool
|
||||
CIMDGrantedPermissionIDs []string
|
||||
}
|
||||
|
||||
// ListAPIClients returns, one page at a time, every client that may reach the API, ordered by name, including clients covered by the CIMD opt-in (which are marked)
|
||||
// The clients are paginated so an opted-in API does not load and return every metadata document client in the instance at once
|
||||
func (s *Service) ListAPIClients(ctx context.Context, apiID, search string, listRequestOptions utils.ListRequestOptions) (result []APIClientAccess, response utils.PaginationResponse, err error) {
|
||||
api, err := s.Get(ctx, nil, apiID)
|
||||
if err != nil {
|
||||
return nil, utils.PaginationResponse{}, err
|
||||
}
|
||||
|
||||
access := make(map[string]*APIClientGrant)
|
||||
grantFor := func(clientID string) *APIClientGrant {
|
||||
entry, ok := access[clientID]
|
||||
if !ok {
|
||||
entry = &APIClientGrant{}
|
||||
access[clientID] = entry
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
// Access rows drive the list: a client with access but no permission still belongs in it
|
||||
var apiRows []OidcClientAllowedAPI
|
||||
err = s.db.WithContext(ctx).
|
||||
Where("api_id = ?", api.ID).
|
||||
Find(&apiRows).
|
||||
Error
|
||||
if err != nil {
|
||||
return nil, utils.PaginationResponse{}, err
|
||||
}
|
||||
for _, row := range apiRows {
|
||||
switch row.SubjectType {
|
||||
case oidc.SubjectTypeClient:
|
||||
grantFor(row.OidcClientID).ClientAccess = true
|
||||
case oidc.SubjectTypeUser:
|
||||
grantFor(row.OidcClientID).UserDelegatedAccess = true
|
||||
}
|
||||
}
|
||||
|
||||
var permissionRows []struct {
|
||||
OidcClientID string
|
||||
APIPermissionID string
|
||||
SubjectType oidc.SubjectType
|
||||
}
|
||||
err = s.db.WithContext(ctx).
|
||||
Table("oidc_clients_allowed_api_permissions AS g").
|
||||
Select("g.oidc_client_id AS oidc_client_id, g.api_permission_id AS api_permission_id, g.subject_type AS subject_type").
|
||||
Joins("JOIN api_permissions ON api_permissions.id = g.api_permission_id").
|
||||
Where("api_permissions.api_id = ?", api.ID).
|
||||
Scan(&permissionRows).
|
||||
Error
|
||||
if err != nil {
|
||||
return nil, utils.PaginationResponse{}, err
|
||||
}
|
||||
for _, row := range permissionRows {
|
||||
entry := grantFor(row.OidcClientID)
|
||||
switch row.SubjectType {
|
||||
case oidc.SubjectTypeClient:
|
||||
entry.ClientPermissionIDs = append(entry.ClientPermissionIDs, row.APIPermissionID)
|
||||
case oidc.SubjectTypeUser:
|
||||
entry.UserDelegatedPermissionIDs = append(entry.UserDelegatedPermissionIDs, row.APIPermissionID)
|
||||
}
|
||||
}
|
||||
|
||||
// Permissions a CIMD client receives come from the API's opt-in selection, the same for every such client
|
||||
var cimdPermissionIDs []string
|
||||
if api.AllowCIMDClients {
|
||||
err = s.db.WithContext(ctx).
|
||||
Model(&Permission{}).
|
||||
Where("api_id = ? AND allowed_for_cimd_clients = ?", api.ID, true).
|
||||
Pluck("id", &cimdPermissionIDs).
|
||||
Error
|
||||
if err != nil {
|
||||
return nil, utils.PaginationResponse{}, err
|
||||
}
|
||||
}
|
||||
|
||||
// Load the matching clients one page at a time: those with an explicit grant, plus every CIMD client when the API opted in
|
||||
explicitIDs := slices.Collect(maps.Keys(access))
|
||||
query := s.db.WithContext(ctx).Model(&model.OidcClient{})
|
||||
switch {
|
||||
case api.AllowCIMDClients && len(explicitIDs) > 0:
|
||||
query = query.Where("id IN ? OR client_type = ?", explicitIDs, model.OidcClientTypeCIMD)
|
||||
case api.AllowCIMDClients:
|
||||
query = query.Where("client_type = ?", model.OidcClientTypeCIMD)
|
||||
case len(explicitIDs) > 0:
|
||||
query = query.Where("id IN ?", explicitIDs)
|
||||
default:
|
||||
// No client can reach the API, so match nothing while still returning a well-formed page
|
||||
query = query.Where("1 = 0")
|
||||
}
|
||||
if search != "" {
|
||||
query = query.Where("name LIKE ?", "%"+search+"%")
|
||||
}
|
||||
|
||||
// Keep the list stably ordered by name when the caller does not ask for a specific sort
|
||||
if listRequestOptions.Sort.Column == "" {
|
||||
listRequestOptions.Sort.Column = "name"
|
||||
listRequestOptions.Sort.Direction = "asc"
|
||||
}
|
||||
|
||||
var clients []model.OidcClient
|
||||
response, err = utils.PaginateFilterAndSort(listRequestOptions, query, &clients)
|
||||
if err != nil {
|
||||
return nil, utils.PaginationResponse{}, err
|
||||
}
|
||||
|
||||
result = make([]APIClientAccess, 0, len(clients))
|
||||
for _, client := range clients {
|
||||
grant := APIClientGrant{}
|
||||
if existing, ok := access[client.ID]; ok {
|
||||
grant = *existing
|
||||
}
|
||||
grant.UserDelegatedPermissionIDs = orEmptyIDs(grant.UserDelegatedPermissionIDs)
|
||||
grant.ClientPermissionIDs = orEmptyIDs(grant.ClientPermissionIDs)
|
||||
|
||||
entry := APIClientAccess{Client: client, APIClientGrant: grant, CIMDGrantedPermissionIDs: []string{}}
|
||||
if api.AllowCIMDClients && client.ClientType == model.OidcClientTypeCIMD {
|
||||
entry.CIMDGrantedAccess = true
|
||||
entry.CIMDGrantedPermissionIDs = cimdPermissionIDs
|
||||
}
|
||||
result = append(result, entry)
|
||||
}
|
||||
|
||||
return result, response, nil
|
||||
}
|
||||
|
||||
// ListAssignableClients returns the clients that hold no grant on the API yet, so only what can still be added is offered
|
||||
func (s *Service) ListAssignableClients(ctx context.Context, apiID, search string, listRequestOptions utils.ListRequestOptions) (clients []model.OidcClient, response utils.PaginationResponse, err error) {
|
||||
api, err := s.Get(ctx, nil, apiID)
|
||||
if err != nil {
|
||||
return nil, utils.PaginationResponse{}, err
|
||||
}
|
||||
|
||||
granted := s.db.
|
||||
Model(&OidcClientAllowedAPI{}).
|
||||
Select("oidc_client_id").
|
||||
Where("api_id = ?", api.ID)
|
||||
|
||||
query := s.db.
|
||||
WithContext(ctx).
|
||||
Model(&model.OidcClient{}).
|
||||
Where("id NOT IN (?)", granted)
|
||||
|
||||
// CIMD clients already reach an opted-in API and are listed for it, so don't offer them again
|
||||
if api.AllowCIMDClients {
|
||||
query = query.Where("client_type <> ?", model.OidcClientTypeCIMD)
|
||||
}
|
||||
|
||||
if search != "" {
|
||||
query = query.Where("name LIKE ?", "%"+search+"%")
|
||||
}
|
||||
|
||||
response, err = utils.PaginateFilterAndSort(listRequestOptions, query, &clients)
|
||||
return clients, response, err
|
||||
}
|
||||
|
||||
// ListAssignableAPIs returns the APIs the client cannot already reach, which are the ones missing from its access list
|
||||
func (s *Service) ListAssignableAPIs(ctx context.Context, clientID, search string, listRequestOptions utils.ListRequestOptions) (apis []API, response utils.PaginationResponse, err error) {
|
||||
if err = ensureOIDCClientExists(ctx, s.db, clientID); err != nil {
|
||||
return nil, utils.PaginationResponse{}, err
|
||||
}
|
||||
|
||||
granted := s.db.
|
||||
Model(&OidcClientAllowedAPI{}).
|
||||
Select("api_id").
|
||||
Where("oidc_client_id = ?", clientID)
|
||||
|
||||
query := s.db.
|
||||
WithContext(ctx).
|
||||
Preload("Permissions").
|
||||
Model(&API{}).
|
||||
Where("id NOT IN (?)", granted)
|
||||
|
||||
// APIs reached through the CIMD opt-in are already listed for the client, so don't offer them again
|
||||
cimdGranted, err := s.cimdGrantedAccess(ctx, s.db, clientID, "")
|
||||
if err != nil {
|
||||
return nil, utils.PaginationResponse{}, err
|
||||
}
|
||||
if cimdGranted.grantsAccess() {
|
||||
query = query.Where("id NOT IN ?", cimdGranted.apiIDs())
|
||||
}
|
||||
|
||||
if search != "" {
|
||||
like := "%" + search + "%"
|
||||
query = query.Where("name LIKE ? OR audience LIKE ?", like, like)
|
||||
}
|
||||
|
||||
if listRequestOptions.Sort.Column == "resource" {
|
||||
listRequestOptions.Sort.Column = "audience"
|
||||
}
|
||||
|
||||
response, err = utils.PaginateFilterAndSort(listRequestOptions, query, &apis)
|
||||
return apis, response, err
|
||||
}
|
||||
|
||||
// SetAPIClientAccess replaces a single client's grants on one API, leaving whatever it was granted on other APIs untouched
|
||||
// Permission IDs that do not belong to this API are ignored, and a permission implies access for its subject type
|
||||
func (s *Service) SetAPIClientAccess(ctx context.Context, apiID, clientID string, grant APIClientGrant) (applied APIClientGrant, err error) {
|
||||
tx := s.db.Begin()
|
||||
defer func() {
|
||||
tx.Rollback()
|
||||
}()
|
||||
|
||||
if err = ensureOIDCClientExists(ctx, tx, clientID); err != nil {
|
||||
return ClientAPIAccess{}, err
|
||||
api, err := s.Get(ctx, tx, apiID)
|
||||
if err != nil {
|
||||
return APIClientGrant{}, err
|
||||
}
|
||||
client, err := loadOIDCClient(ctx, tx, clientID)
|
||||
if err != nil {
|
||||
return APIClientGrant{}, err
|
||||
}
|
||||
|
||||
applied.UserDelegatedPermissionIDs, err = s.filterAssignablePermissionIDs(ctx, tx, access.UserDelegatedPermissionIDs)
|
||||
if err != nil {
|
||||
return ClientAPIAccess{}, err
|
||||
permissionIDs := collectIDs(api.Permissions)
|
||||
applied.UserDelegatedPermissionIDs = intersectIDs(permissionIDs, grant.UserDelegatedPermissionIDs)
|
||||
applied.ClientPermissionIDs = intersectIDs(permissionIDs, grant.ClientPermissionIDs)
|
||||
// A public client can't use the client credentials grant, so drop any client-subject grant that could never produce a token
|
||||
if client.IsPublic {
|
||||
grant.ClientAccess = false
|
||||
applied.ClientPermissionIDs = nil
|
||||
}
|
||||
applied.ClientPermissionIDs, err = s.filterAssignablePermissionIDs(ctx, tx, access.ClientPermissionIDs)
|
||||
if err != nil {
|
||||
return ClientAPIAccess{}, err
|
||||
applied.UserDelegatedAccess = grant.UserDelegatedAccess || len(applied.UserDelegatedPermissionIDs) > 0
|
||||
applied.ClientAccess = grant.ClientAccess || len(applied.ClientPermissionIDs) > 0
|
||||
|
||||
if err = deleteAPIClientGrants(ctx, tx, api.ID, permissionIDs, clientID); err != nil {
|
||||
return APIClientGrant{}, 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
|
||||
access := ClientAPIAccess{
|
||||
UserDelegatedPermissionIDs: applied.UserDelegatedPermissionIDs,
|
||||
ClientPermissionIDs: applied.ClientPermissionIDs,
|
||||
}
|
||||
|
||||
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})
|
||||
if applied.UserDelegatedAccess {
|
||||
access.UserDelegatedAPIIDs = []string{api.ID}
|
||||
}
|
||||
for _, permissionID := range applied.ClientPermissionIDs {
|
||||
rows = append(rows, OidcClientAllowedAPIPermission{OidcClientID: clientID, APIPermissionID: permissionID, SubjectType: oidc.SubjectTypeClient})
|
||||
if applied.ClientAccess {
|
||||
access.ClientAPIIDs = []string{api.ID}
|
||||
}
|
||||
if len(rows) > 0 {
|
||||
if err = tx.WithContext(ctx).Create(&rows).Error; err != nil {
|
||||
return ClientAPIAccess{}, err
|
||||
}
|
||||
if err = insertClientGrants(ctx, tx, clientID, access); err != nil {
|
||||
return APIClientGrant{}, err
|
||||
}
|
||||
|
||||
if err = tx.Commit().Error; err != nil {
|
||||
return ClientAPIAccess{}, err
|
||||
return APIClientGrant{}, err
|
||||
}
|
||||
|
||||
return applied, nil
|
||||
}
|
||||
|
||||
// RemoveAPIClientAccess revokes every grant a client holds on one API
|
||||
func (s *Service) RemoveAPIClientAccess(ctx context.Context, apiID, clientID string) error {
|
||||
tx := s.db.Begin()
|
||||
defer func() {
|
||||
tx.Rollback()
|
||||
}()
|
||||
|
||||
api, err := s.Get(ctx, tx, apiID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Reject an unknown client id so it can't delete nothing and still report success
|
||||
if err = ensureOIDCClientExists(ctx, tx, clientID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = deleteAPIClientGrants(ctx, tx, api.ID, collectIDs(api.Permissions), clientID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Commit().Error
|
||||
}
|
||||
|
||||
// deleteAPIClientGrants removes a client's access and permission grants for one API, leaving its grants on other APIs untouched
|
||||
func deleteAPIClientGrants(ctx context.Context, tx *gorm.DB, apiID string, permissionIDs []string, clientID string) error {
|
||||
err := tx.WithContext(ctx).
|
||||
Where("oidc_client_id = ? AND api_id = ?", clientID, apiID).
|
||||
Delete(&OidcClientAllowedAPI{}).
|
||||
Error
|
||||
if err != nil || len(permissionIDs) == 0 {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.WithContext(ctx).
|
||||
Where("oidc_client_id = ? AND api_permission_id IN ?", clientID, permissionIDs).
|
||||
Delete(&OidcClientAllowedAPIPermission{}).
|
||||
Error
|
||||
}
|
||||
|
||||
// orEmptyIDs keeps an ID list non-nil so it serializes as [] rather than null, which the admin UI indexes into directly
|
||||
func orEmptyIDs(ids []string) []string {
|
||||
if ids == nil {
|
||||
return []string{}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// intersectIDs returns the requested IDs that are part of available, preserving the order of available
|
||||
func intersectIDs(available, requested []string) []string {
|
||||
if len(requested) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := make([]string, 0, len(requested))
|
||||
for _, id := range available {
|
||||
if slices.Contains(requested, id) {
|
||||
result = append(result, id)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func ensureOIDCClientExists(ctx context.Context, db *gorm.DB, clientID string) error {
|
||||
_, err := loadOIDCClient(ctx, db, clientID)
|
||||
return err
|
||||
}
|
||||
|
||||
// loadOIDCClient reads the few client fields the grant paths need to decide what a client may be granted
|
||||
func loadOIDCClient(ctx context.Context, db *gorm.DB, clientID string) (model.OidcClient, error) {
|
||||
var client model.OidcClient
|
||||
err := db.WithContext(ctx).
|
||||
Select("id").
|
||||
Select("id", "is_public", "client_type").
|
||||
Where("id = ?", clientID).
|
||||
First(&client).
|
||||
Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return apperror.NotFound("OIDC client")
|
||||
return model.OidcClient{}, apperror.NotFound("OIDC client")
|
||||
}
|
||||
|
||||
return err
|
||||
return client, err
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// ClientAPIScopesAndAudiences returns the permission keys a client may request and the distinct audiences of every custom API it may reach, across both subject types
|
||||
// The OIDC module uses this to widen fosite's scope and audience validation; per-flow subject-type enforcement happens when the resource is resolved
|
||||
// An accessible API contributes its audience even without any permission, so a scopeless resource request isn't rejected before it is resolved
|
||||
func (s *Service) ClientAPIScopesAndAudiences(ctx context.Context, tx *gorm.DB, clientID string, isCIMDClient bool) (scopes []string, audiences []string, err error) {
|
||||
if tx == nil {
|
||||
tx = s.db
|
||||
}
|
||||
|
||||
var rows []struct {
|
||||
Key string
|
||||
Audience string
|
||||
}
|
||||
var audienceRows []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").
|
||||
Table("oidc_clients_allowed_apis AS g").
|
||||
Joins("JOIN apis ON apis.id = g.api_id").
|
||||
Where("g.oidc_client_id = ?", clientID).
|
||||
Scan(&rows).
|
||||
Pluck("apis.audience", &audienceRows).
|
||||
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)
|
||||
}
|
||||
var scopeRows []string
|
||||
err = tx.WithContext(ctx).
|
||||
Table("oidc_clients_allowed_api_permissions AS g").
|
||||
Joins("JOIN api_permissions ON api_permissions.id = g.api_permission_id").
|
||||
Where("g.oidc_client_id = ?", clientID).
|
||||
Pluck("api_permissions.key", &scopeRows).
|
||||
Error
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return scopes, audiences, nil
|
||||
// CIMD clients additionally reach every API that opted in to them
|
||||
if isCIMDClient {
|
||||
cimdGranted, err := s.cimdGrantedAccess(ctx, tx, clientID, "")
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
audienceRows = append(audienceRows, cimdGranted.audiences()...)
|
||||
scopeRows = append(scopeRows, cimdGranted.scopes()...)
|
||||
}
|
||||
|
||||
return distinct(scopeRows), distinct(audienceRows), 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, tx *gorm.DB, clientID, audience string, subjectType oidc.SubjectType) (scopes []string, apiExists bool, err error) {
|
||||
// AllowedScopesForAudience returns the permission keys the client is allowed for the API identified by the given audience and subject type, whether such an API exists, and whether the client may reach it at all
|
||||
func (s *Service) AllowedScopesForAudience(ctx context.Context, tx *gorm.DB, clientID, audience string, subjectType oidc.SubjectType) (scopes []string, apiExists bool, hasAccess bool, err error) {
|
||||
if tx == nil {
|
||||
tx = s.db
|
||||
}
|
||||
|
||||
var api API
|
||||
err = tx.WithContext(ctx).
|
||||
Select("id").
|
||||
Select("id", "allow_cimd_clients").
|
||||
Where("audience = ?", audience).
|
||||
First(&api).
|
||||
Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, false, nil
|
||||
return nil, false, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
return nil, false, false, err
|
||||
}
|
||||
|
||||
var grantCount int64
|
||||
err = tx.WithContext(ctx).
|
||||
Model(&OidcClientAllowedAPI{}).
|
||||
Where("oidc_client_id = ? AND api_id = ? AND subject_type = ?", clientID, api.ID, subjectType).
|
||||
Count(&grantCount).
|
||||
Error
|
||||
if err != nil {
|
||||
return nil, true, false, err
|
||||
}
|
||||
hasAccess = grantCount > 0
|
||||
|
||||
err = tx.WithContext(ctx).
|
||||
Table("api_permissions").
|
||||
Select("api_permissions.key").
|
||||
@@ -433,10 +912,114 @@ func (s *Service) AllowedScopesForAudience(ctx context.Context, tx *gorm.DB, cli
|
||||
Pluck("api_permissions.key", &scopes).
|
||||
Error
|
||||
if err != nil {
|
||||
return nil, true, err
|
||||
return nil, true, hasAccess, err
|
||||
}
|
||||
|
||||
return scopes, true, nil
|
||||
// The opt-in only covers user-delegated access: CIMD clients are public and can't use the client credentials grant
|
||||
if api.AllowCIMDClients && subjectType == oidc.SubjectTypeUser {
|
||||
cimdGranted, err := s.cimdGrantedAccess(ctx, tx, clientID, api.ID)
|
||||
if err != nil {
|
||||
return nil, true, hasAccess, err
|
||||
}
|
||||
if cimdGranted.grantsAccess() {
|
||||
hasAccess = true
|
||||
}
|
||||
for _, scope := range cimdGranted.scopes() {
|
||||
if !slices.Contains(scopes, scope) {
|
||||
scopes = append(scopes, scope)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return scopes, true, hasAccess, nil
|
||||
}
|
||||
|
||||
func distinct(values []string) []string {
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
result := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
if _, ok := seen[value]; ok {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
result = append(result, value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// cimdGrantedAccessRows is what the API-wide CIMD opt-in gives one client
|
||||
// APIs stand on their own: one can opt in without selecting any permission, granting access with no scope
|
||||
type cimdGrantedAccessRows struct {
|
||||
APIs []struct {
|
||||
ID string
|
||||
Audience string
|
||||
}
|
||||
Permissions []struct {
|
||||
ID string
|
||||
Key string
|
||||
APIID string
|
||||
}
|
||||
}
|
||||
|
||||
func (r cimdGrantedAccessRows) grantsAccess() bool { return len(r.APIs) > 0 }
|
||||
|
||||
func (r cimdGrantedAccessRows) apiIDs() []string {
|
||||
ids := make([]string, len(r.APIs))
|
||||
for i, api := range r.APIs {
|
||||
ids[i] = api.ID
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func (r cimdGrantedAccessRows) audiences() []string {
|
||||
audiences := make([]string, len(r.APIs))
|
||||
for i, api := range r.APIs {
|
||||
audiences[i] = api.Audience
|
||||
}
|
||||
return audiences
|
||||
}
|
||||
|
||||
func (r cimdGrantedAccessRows) scopes() []string {
|
||||
keys := make([]string, len(r.Permissions))
|
||||
for i, permission := range r.Permissions {
|
||||
keys[i] = permission.Key
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// cimdGrantedAccess returns the APIs a client may reach and the permissions it may request through the CIMD opt-in, optionally narrowed to a single API
|
||||
// The CIMD check is a join, so a client registered the regular way simply matches no rows
|
||||
func (s *Service) cimdGrantedAccess(ctx context.Context, tx *gorm.DB, clientID, apiID string) (rows cimdGrantedAccessRows, err error) {
|
||||
if tx == nil {
|
||||
tx = s.db
|
||||
}
|
||||
|
||||
apiQuery := tx.WithContext(ctx).
|
||||
Table("apis").
|
||||
Select("apis.id AS id, apis.audience AS audience").
|
||||
Joins("JOIN oidc_clients ON oidc_clients.id = ? AND oidc_clients.client_type = ?", clientID, model.OidcClientTypeCIMD).
|
||||
Where("apis.allow_cimd_clients = ?", true)
|
||||
if apiID != "" {
|
||||
apiQuery = apiQuery.Where("apis.id = ?", apiID)
|
||||
}
|
||||
if err = apiQuery.Scan(&rows.APIs).Error; err != nil {
|
||||
return cimdGrantedAccessRows{}, err
|
||||
}
|
||||
if len(rows.APIs) == 0 {
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
err = tx.WithContext(ctx).
|
||||
Table("api_permissions").
|
||||
Select("api_permissions.id AS id, api_permissions.key AS key, api_permissions.api_id AS api_id").
|
||||
Where("api_permissions.api_id IN ? AND api_permissions.allowed_for_cimd_clients = ?", rows.apiIDs(), true).
|
||||
Scan(&rows.Permissions).
|
||||
Error
|
||||
if err != nil {
|
||||
return cimdGrantedAccessRows{}, err
|
||||
}
|
||||
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// DescribePermissions returns the permission rows of the API identified by the given audience whose key is in keys
|
||||
@@ -463,27 +1046,8 @@ func (s *Service) DescribePermissions(ctx context.Context, audience string, keys
|
||||
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
|
||||
// deletePermissions removes permissions by ID; deleting them cascades to any client permission grants that reference them (oidc_clients_allowed_api_permissions.api_permission_id ON DELETE CASCADE)
|
||||
// A client's access to an API is dropped together with the last permission it held there, so removing a permission never leaves a client with lingering scopeless access it never asked for; access granted without any permission is untouched because such a client holds none of the deleted permissions
|
||||
func (s *Service) deletePermissions(ctx context.Context, tx *gorm.DB, permissionIDs []string) error {
|
||||
if tx == nil {
|
||||
tx = s.db
|
||||
@@ -492,18 +1056,56 @@ func (s *Service) deletePermissions(ctx context.Context, tx *gorm.DB, permission
|
||||
return nil
|
||||
}
|
||||
|
||||
// Record which clients held these permissions, and on which API and subject type, before the grants are gone
|
||||
var affected []struct {
|
||||
OidcClientID string
|
||||
APIID string `gorm:"column:api_id"`
|
||||
SubjectType oidc.SubjectType
|
||||
}
|
||||
err := tx.WithContext(ctx).
|
||||
Where("api_permission_id IN ?", permissionIDs).
|
||||
Delete(&OidcClientAllowedAPIPermission{}).
|
||||
Table("oidc_clients_allowed_api_permissions AS g").
|
||||
Select("DISTINCT g.oidc_client_id AS oidc_client_id, api_permissions.api_id AS api_id, g.subject_type AS subject_type").
|
||||
Joins("JOIN api_permissions ON api_permissions.id = g.api_permission_id").
|
||||
Where("g.api_permission_id IN ?", permissionIDs).
|
||||
Scan(&affected).
|
||||
Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.WithContext(ctx).
|
||||
if err = tx.WithContext(ctx).
|
||||
Where("id IN ?", permissionIDs).
|
||||
Delete(&Permission{}).
|
||||
Error
|
||||
Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Drop the access a deleted permission implied, unless the client still holds another permission of the same API for that subject type
|
||||
for _, row := range affected {
|
||||
var remaining int64
|
||||
err = tx.WithContext(ctx).
|
||||
Table("oidc_clients_allowed_api_permissions AS g").
|
||||
Joins("JOIN api_permissions ON api_permissions.id = g.api_permission_id").
|
||||
Where("g.oidc_client_id = ? AND api_permissions.api_id = ? AND g.subject_type = ?", row.OidcClientID, row.APIID, row.SubjectType).
|
||||
Count(&remaining).
|
||||
Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if remaining > 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
err = tx.WithContext(ctx).
|
||||
Where("oidc_client_id = ? AND api_id = ? AND subject_type = ?", row.OidcClientID, row.APIID, row.SubjectType).
|
||||
Delete(&OidcClientAllowedAPI{}).
|
||||
Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func collectIDs(permissions []Permission) []string {
|
||||
|
||||
@@ -9,11 +9,15 @@ import (
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apperror"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/oidc"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
|
||||
)
|
||||
|
||||
func TestAPICrudAndPermissionDiff(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
// Removing a permission (or an API) relies on the ON DELETE CASCADE that production enforces, so exercise it here
|
||||
// The shared test harness disables foreign keys, so enable them for this connection
|
||||
require.NoError(t, db.Exec("PRAGMA foreign_keys = ON").Error)
|
||||
svc := New(Dependencies{DB: db}).service
|
||||
|
||||
created, err := svc.Create(t.Context(), apiCreateDto{Name: "Orders API", Resource: "https://api.orders.example.com"})
|
||||
@@ -62,6 +66,20 @@ func TestAPICrudAndPermissionDiff(t *testing.T) {
|
||||
require.True(t, apperror.IsCode(err, apperror.CodeNotFound))
|
||||
}
|
||||
|
||||
// clientGrantFor returns the client's grant on one API from the client-side listing, which is what remains after the
|
||||
// per-client bulk setter/getter were removed in favour of the single write path on /apis/:id/clients/:clientId.
|
||||
func clientGrantFor(t *testing.T, svc *Service, clientID, apiID string) ClientAPIGrant {
|
||||
t.Helper()
|
||||
grants, err := svc.ListClientAPIs(t.Context(), clientID)
|
||||
require.NoError(t, err)
|
||||
for _, grant := range grants {
|
||||
if grant.API.ID == apiID {
|
||||
return grant
|
||||
}
|
||||
}
|
||||
return ClientAPIGrant{}
|
||||
}
|
||||
|
||||
func TestClientApiAccessAllowList(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
svc := New(Dependencies{DB: db}).service
|
||||
@@ -78,55 +96,96 @@ func TestClientApiAccessAllowList(t *testing.T) {
|
||||
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{
|
||||
// Unknown IDs are filtered out, the subject types are stored independently, and a permission implies access to its API.
|
||||
applied, err := svc.SetAPIClientAccess(t.Context(), orders.ID, "client-1", APIClientGrant{
|
||||
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)
|
||||
assert.True(t, applied.UserDelegatedAccess)
|
||||
assert.True(t, applied.ClientAccess)
|
||||
|
||||
got, err := svc.GetClientAPIAccess(t.Context(), "client-1")
|
||||
require.NoError(t, err)
|
||||
got := clientGrantFor(t, svc, "client-1", orders.ID)
|
||||
assert.ElementsMatch(t, []string{readID}, got.UserDelegatedPermissionIDs)
|
||||
assert.ElementsMatch(t, []string{writeID}, got.ClientPermissionIDs)
|
||||
assert.True(t, got.UserDelegatedAccess)
|
||||
assert.True(t, got.ClientAccess)
|
||||
|
||||
// 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{
|
||||
_, err = svc.SetAPIClientAccess(t.Context(), orders.ID, "client-1", APIClientGrant{
|
||||
UserDelegatedPermissionIDs: []string{readID, writeID},
|
||||
ClientPermissionIDs: []string{readID},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
got, err = svc.GetClientAPIAccess(t.Context(), "client-1")
|
||||
require.NoError(t, err)
|
||||
got = clientGrantFor(t, svc, "client-1", orders.ID)
|
||||
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")
|
||||
_, err = svc.SetAPIClientAccess(t.Context(), orders.ID, "client-1", APIClientGrant{ClientPermissionIDs: []string{readID}})
|
||||
require.NoError(t, err)
|
||||
got = clientGrantFor(t, svc, "client-1", orders.ID)
|
||||
assert.Empty(t, got.UserDelegatedPermissionIDs)
|
||||
assert.False(t, got.UserDelegatedAccess)
|
||||
assert.ElementsMatch(t, []string{readID}, got.ClientPermissionIDs)
|
||||
|
||||
// Clearing everything.
|
||||
_, err = svc.SetClientAPIAccess(t.Context(), "client-1", ClientAPIAccess{})
|
||||
// Clearing everything drops the API from the client's list entirely.
|
||||
_, err = svc.SetAPIClientAccess(t.Context(), orders.ID, "client-1", APIClientGrant{})
|
||||
require.NoError(t, err)
|
||||
got, err = svc.GetClientAPIAccess(t.Context(), "client-1")
|
||||
grants, err := svc.ListClientAPIs(t.Context(), "client-1")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, got.UserDelegatedPermissionIDs)
|
||||
assert.Empty(t, got.ClientPermissionIDs)
|
||||
assert.Empty(t, grants)
|
||||
|
||||
// An API can be granted on its own, without a single permission.
|
||||
applied, err = svc.SetAPIClientAccess(t.Context(), orders.ID, "client-1", APIClientGrant{UserDelegatedAccess: true})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, applied.UserDelegatedAccess)
|
||||
assert.Empty(t, applied.UserDelegatedPermissionIDs)
|
||||
got = clientGrantFor(t, svc, "client-1", orders.ID)
|
||||
assert.True(t, got.UserDelegatedAccess)
|
||||
assert.False(t, got.ClientAccess)
|
||||
|
||||
// An unknown client is rejected (surfaces as 404 at the HTTP layer).
|
||||
_, err = svc.SetClientAPIAccess(t.Context(), "nope", ClientAPIAccess{UserDelegatedPermissionIDs: []string{readID}})
|
||||
_, err = svc.SetAPIClientAccess(t.Context(), orders.ID, "nope", APIClientGrant{UserDelegatedPermissionIDs: []string{readID}})
|
||||
require.True(t, apperror.IsCode(err, apperror.CodeNotFound))
|
||||
|
||||
_, err = svc.GetClientAPIAccess(t.Context(), "nope")
|
||||
_, err = svc.ListClientAPIs(t.Context(), "nope")
|
||||
require.True(t, apperror.IsCode(err, apperror.CodeNotFound))
|
||||
}
|
||||
|
||||
// TestSetAPIClientAccessDropsClientGrantsForPublicClients guards that a machine-to-machine grant cannot be written for a
|
||||
// client that can never authenticate for the client credentials grant, even by a direct API call that bypasses the UI.
|
||||
func TestSetAPIClientAccessDropsClientGrantsForPublicClients(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
svc := New(Dependencies{DB: db}).service
|
||||
|
||||
require.NoError(t, db.Create(&model.OidcClient{Base: model.Base{ID: "public-1"}, Name: "Public", IsPublic: true}).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: "write:orders", Name: "Write"},
|
||||
}})
|
||||
require.NoError(t, err)
|
||||
writeID := findPermission(orders, "write:orders").ID
|
||||
|
||||
applied, err := svc.SetAPIClientAccess(t.Context(), orders.ID, "public-1", APIClientGrant{
|
||||
UserDelegatedAccess: true,
|
||||
ClientAccess: true,
|
||||
ClientPermissionIDs: []string{writeID},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, applied.ClientAccess)
|
||||
assert.Empty(t, applied.ClientPermissionIDs)
|
||||
assert.True(t, applied.UserDelegatedAccess)
|
||||
|
||||
_, _, hasAccess, err := svc.AllowedScopesForAudience(t.Context(), nil, "public-1", "https://api.orders.example.com", oidc.SubjectTypeClient)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, hasAccess)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@@ -145,29 +204,74 @@ func TestAllowedScopesForAudienceFiltersBySubjectType(t *testing.T) {
|
||||
readID := findPermission(orders, "read:orders").ID
|
||||
writeID := findPermission(orders, "write:orders").ID
|
||||
|
||||
_, err = svc.SetClientAPIAccess(t.Context(), "client-1", ClientAPIAccess{
|
||||
_, err = svc.SetAPIClientAccess(t.Context(), orders.ID, "client-1", APIClientGrant{
|
||||
UserDelegatedPermissionIDs: []string{readID},
|
||||
ClientPermissionIDs: []string{writeID},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
userScopes, exists, err := svc.AllowedScopesForAudience(t.Context(), nil, "client-1", "https://api.orders.example.com", oidc.SubjectTypeUser)
|
||||
userScopes, exists, hasAccess, err := svc.AllowedScopesForAudience(t.Context(), nil, "client-1", "https://api.orders.example.com", oidc.SubjectTypeUser)
|
||||
require.NoError(t, err)
|
||||
require.True(t, exists)
|
||||
require.True(t, hasAccess)
|
||||
assert.ElementsMatch(t, []string{"read:orders"}, userScopes)
|
||||
|
||||
clientScopes, exists, err := svc.AllowedScopesForAudience(t.Context(), nil, "client-1", "https://api.orders.example.com", oidc.SubjectTypeClient)
|
||||
clientScopes, exists, hasAccess, err := svc.AllowedScopesForAudience(t.Context(), nil, "client-1", "https://api.orders.example.com", oidc.SubjectTypeClient)
|
||||
require.NoError(t, err)
|
||||
require.True(t, exists)
|
||||
require.True(t, hasAccess)
|
||||
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")
|
||||
scopes, audiences, err := svc.ClientAPIScopesAndAudiences(t.Context(), nil, "client-1", false)
|
||||
require.NoError(t, err)
|
||||
assert.ElementsMatch(t, []string{"read:orders", "write:orders"}, scopes)
|
||||
assert.ElementsMatch(t, []string{"https://api.orders.example.com"}, audiences)
|
||||
}
|
||||
|
||||
// TestAccessWithoutPermissions covers granting an API to a client without any permission, which is what
|
||||
// an MCP client needs: the resource is reachable and the token simply carries no custom scope.
|
||||
func TestAccessWithoutPermissions(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
svc := New(Dependencies{DB: db}).service
|
||||
|
||||
const resource = "https://api.orders.example.com"
|
||||
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: resource})
|
||||
require.NoError(t, err)
|
||||
orders, err = svc.UpdatePermissions(t.Context(), orders.ID, apiPermissionsUpdateDto{Permissions: []apiPermissionInputDto{
|
||||
{Key: "read:orders", Name: "Read"},
|
||||
}})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Without any grant the client cannot reach the API at all
|
||||
scopes, exists, hasAccess, err := svc.AllowedScopesForAudience(t.Context(), nil, "client-1", resource, oidc.SubjectTypeUser)
|
||||
require.NoError(t, err)
|
||||
require.True(t, exists)
|
||||
assert.False(t, hasAccess)
|
||||
assert.Empty(t, scopes)
|
||||
|
||||
_, err = svc.SetAPIClientAccess(t.Context(), orders.ID, "client-1", APIClientGrant{UserDelegatedAccess: true})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Access is granted for user-delegated flows only, and it comes with no scope
|
||||
scopes, _, hasAccess, err = svc.AllowedScopesForAudience(t.Context(), nil, "client-1", resource, oidc.SubjectTypeUser)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, hasAccess)
|
||||
assert.Empty(t, scopes)
|
||||
|
||||
_, _, hasAccess, err = svc.AllowedScopesForAudience(t.Context(), nil, "client-1", resource, oidc.SubjectTypeClient)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, hasAccess)
|
||||
|
||||
// Fosite still has to accept the audience, otherwise the request is rejected before the resource is resolved
|
||||
scopes, audiences, err := svc.ClientAPIScopesAndAudiences(t.Context(), nil, "client-1", false)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, scopes)
|
||||
assert.ElementsMatch(t, []string{resource}, audiences)
|
||||
}
|
||||
|
||||
func TestUpdatePermissionsRejectsReservedKeys(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
svc := New(Dependencies{DB: db}).service
|
||||
@@ -285,6 +389,256 @@ func TestDescribePermissions(t *testing.T) {
|
||||
assert.Equal(t, "Read orders", *infos[0].Description)
|
||||
}
|
||||
|
||||
// TestCimdClientAccess covers the API-wide opt-in that lets every metadata document client reach an API without an individual grant.
|
||||
func TestCimdClientAccess(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
svc := New(Dependencies{DB: db}).service
|
||||
|
||||
const cimdClientID = "https://app.example.com/oauth-client.json"
|
||||
const resource = "https://api.orders.example.com"
|
||||
require.NoError(t, db.Create(&model.OidcClient{Base: model.Base{ID: cimdClientID}, Name: "MCP client", ClientType: model.OidcClientTypeCIMD, IsPublic: true}).Error)
|
||||
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: resource})
|
||||
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
|
||||
|
||||
// Before the API opts in, a metadata document client has no more access than any other client.
|
||||
scopes, exists, hasAccess, err := svc.AllowedScopesForAudience(t.Context(), nil, cimdClientID, resource, oidc.SubjectTypeUser)
|
||||
require.NoError(t, err)
|
||||
require.True(t, exists)
|
||||
assert.False(t, hasAccess)
|
||||
assert.Empty(t, scopes)
|
||||
|
||||
// Permissions of other APIs are ignored, the same way unknown client grants are.
|
||||
updated, err := svc.SetCIMDAccess(t.Context(), orders.ID, apiCimdAccessUpdateDto{Enabled: true, PermissionIDs: []string{readID, "does-not-exist"}})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, updated.AllowCIMDClients)
|
||||
assert.True(t, findPermission(updated, "read:orders").AllowedForCIMDClients)
|
||||
assert.False(t, findPermission(updated, "write:orders").AllowedForCIMDClients)
|
||||
|
||||
// The metadata document client now reaches the API without an individual grant, but only with the selected permissions.
|
||||
scopes, exists, hasAccess, err = svc.AllowedScopesForAudience(t.Context(), nil, cimdClientID, resource, oidc.SubjectTypeUser)
|
||||
require.NoError(t, err)
|
||||
require.True(t, exists)
|
||||
assert.True(t, hasAccess)
|
||||
assert.ElementsMatch(t, []string{"read:orders"}, scopes)
|
||||
|
||||
// The opt-in never covers the client credentials grant, and never applies to regularly registered clients.
|
||||
scopes, _, hasAccess, err = svc.AllowedScopesForAudience(t.Context(), nil, cimdClientID, resource, oidc.SubjectTypeClient)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, hasAccess)
|
||||
assert.Empty(t, scopes)
|
||||
scopes, _, hasAccess, err = svc.AllowedScopesForAudience(t.Context(), nil, "client-1", resource, oidc.SubjectTypeUser)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, hasAccess)
|
||||
assert.Empty(t, scopes)
|
||||
|
||||
// The fosite scope and audience widening has to see the implicit access too, otherwise the request is rejected before the resource is resolved.
|
||||
widened, audiences, err := svc.ClientAPIScopesAndAudiences(t.Context(), nil, cimdClientID, true)
|
||||
require.NoError(t, err)
|
||||
assert.ElementsMatch(t, []string{"read:orders"}, widened)
|
||||
assert.ElementsMatch(t, []string{resource}, audiences)
|
||||
|
||||
// An individual grant of the same permission does not show up twice.
|
||||
_, err = svc.SetAPIClientAccess(t.Context(), orders.ID, cimdClientID, APIClientGrant{UserDelegatedPermissionIDs: []string{readID}, ClientPermissionIDs: []string{writeID}})
|
||||
require.NoError(t, err)
|
||||
scopes, _, _, err = svc.AllowedScopesForAudience(t.Context(), nil, cimdClientID, resource, oidc.SubjectTypeUser)
|
||||
require.NoError(t, err)
|
||||
assert.ElementsMatch(t, []string{"read:orders"}, scopes)
|
||||
|
||||
// The admin view keeps implicit access apart from the grants that can be edited per client.
|
||||
grant := clientGrantFor(t, svc, cimdClientID, orders.ID)
|
||||
assert.True(t, grant.CIMDGrantedAccess)
|
||||
assert.ElementsMatch(t, []string{readID}, grant.CIMDGrantedPermissionIDs)
|
||||
assert.ElementsMatch(t, []string{readID}, grant.UserDelegatedPermissionIDs)
|
||||
grant = clientGrantFor(t, svc, "client-1", orders.ID)
|
||||
assert.False(t, grant.CIMDGrantedAccess)
|
||||
assert.Empty(t, grant.CIMDGrantedPermissionIDs)
|
||||
|
||||
// Opening the API without selecting any permission still lets a metadata document client reach it, only without a scope.
|
||||
_, err = svc.SetAPIClientAccess(t.Context(), orders.ID, cimdClientID, APIClientGrant{})
|
||||
require.NoError(t, err)
|
||||
_, err = svc.SetCIMDAccess(t.Context(), orders.ID, apiCimdAccessUpdateDto{Enabled: true})
|
||||
require.NoError(t, err)
|
||||
scopes, _, hasAccess, err = svc.AllowedScopesForAudience(t.Context(), nil, cimdClientID, resource, oidc.SubjectTypeUser)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, hasAccess)
|
||||
assert.Empty(t, scopes)
|
||||
_, audiences, err = svc.ClientAPIScopesAndAudiences(t.Context(), nil, cimdClientID, true)
|
||||
require.NoError(t, err)
|
||||
assert.ElementsMatch(t, []string{resource}, audiences)
|
||||
|
||||
// Switching the access off revokes it but keeps the selection, so it can be turned back on unchanged.
|
||||
updated, err = svc.SetCIMDAccess(t.Context(), orders.ID, apiCimdAccessUpdateDto{Enabled: false, PermissionIDs: []string{readID}})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, updated.AllowCIMDClients)
|
||||
assert.True(t, findPermission(updated, "read:orders").AllowedForCIMDClients)
|
||||
_, _, hasAccess, err = svc.AllowedScopesForAudience(t.Context(), nil, cimdClientID, resource, oidc.SubjectTypeUser)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, hasAccess)
|
||||
grant = clientGrantFor(t, svc, cimdClientID, orders.ID)
|
||||
assert.False(t, grant.CIMDGrantedAccess)
|
||||
assert.Empty(t, grant.CIMDGrantedPermissionIDs)
|
||||
|
||||
// An API reached only through the opt-in is still listed for the client, marked as coming from it
|
||||
_, err = svc.SetCIMDAccess(t.Context(), orders.ID, apiCimdAccessUpdateDto{Enabled: true, PermissionIDs: []string{readID}})
|
||||
require.NoError(t, err)
|
||||
clientAPIs, err := svc.ListClientAPIs(t.Context(), cimdClientID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, clientAPIs, 1)
|
||||
assert.Equal(t, orders.ID, clientAPIs[0].API.ID)
|
||||
assert.True(t, clientAPIs[0].CIMDGrantedAccess)
|
||||
assert.ElementsMatch(t, []string{readID}, clientAPIs[0].CIMDGrantedPermissionIDs)
|
||||
assert.False(t, clientAPIs[0].UserDelegatedAccess)
|
||||
assert.Empty(t, clientAPIs[0].UserDelegatedPermissionIDs)
|
||||
|
||||
// It is listed already, so the selection does not offer it a second time
|
||||
assignable, _, err := svc.ListAssignableAPIs(t.Context(), cimdClientID, "", utils.ListRequestOptions{})
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, assignable)
|
||||
assignable, _, err = svc.ListAssignableAPIs(t.Context(), "client-1", "", utils.ListRequestOptions{})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, assignable, 1)
|
||||
}
|
||||
|
||||
// TestApiSideClientGrants covers managing the client grants from the API's side of the relation.
|
||||
func TestApiSideClientGrants(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: "Zulu"}).Error)
|
||||
require.NoError(t, db.Create(&model.OidcClient{Base: model.Base{ID: "client-2"}, Name: "Alpha"}).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
|
||||
|
||||
billing, err := svc.Create(t.Context(), apiCreateDto{Name: "Billing", Resource: "https://api.billing.example.com"})
|
||||
require.NoError(t, err)
|
||||
billing, err = svc.UpdatePermissions(t.Context(), billing.ID, apiPermissionsUpdateDto{Permissions: []apiPermissionInputDto{
|
||||
{Key: "read:invoices", Name: "Read invoices"},
|
||||
}})
|
||||
require.NoError(t, err)
|
||||
invoicesID := findPermission(billing, "read:invoices").ID
|
||||
|
||||
// An API without grants lists no clients.
|
||||
clients, _, err := svc.ListAPIClients(t.Context(), orders.ID, "", utils.ListRequestOptions{})
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, clients)
|
||||
|
||||
// Permissions of another API cannot be granted through this API, and a permission implies access for its subject type.
|
||||
applied, err := svc.SetAPIClientAccess(t.Context(), orders.ID, "client-1", APIClientGrant{
|
||||
UserDelegatedPermissionIDs: []string{readID, invoicesID},
|
||||
ClientPermissionIDs: []string{writeID},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.ElementsMatch(t, []string{readID}, applied.UserDelegatedPermissionIDs)
|
||||
assert.ElementsMatch(t, []string{writeID}, applied.ClientPermissionIDs)
|
||||
assert.True(t, applied.UserDelegatedAccess)
|
||||
assert.True(t, applied.ClientAccess)
|
||||
|
||||
_, err = svc.SetAPIClientAccess(t.Context(), billing.ID, "client-1", APIClientGrant{UserDelegatedPermissionIDs: []string{invoicesID}})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Replacing the grants of one API leaves what the client holds on other APIs untouched.
|
||||
_, err = svc.SetAPIClientAccess(t.Context(), orders.ID, "client-1", APIClientGrant{UserDelegatedPermissionIDs: []string{readID, writeID}})
|
||||
require.NoError(t, err)
|
||||
ordersGrant := clientGrantFor(t, svc, "client-1", orders.ID)
|
||||
billingGrant := clientGrantFor(t, svc, "client-1", billing.ID)
|
||||
assert.ElementsMatch(t, []string{readID, writeID}, ordersGrant.UserDelegatedPermissionIDs)
|
||||
assert.ElementsMatch(t, []string{invoicesID}, billingGrant.UserDelegatedPermissionIDs)
|
||||
assert.True(t, ordersGrant.UserDelegatedAccess)
|
||||
assert.True(t, billingGrant.UserDelegatedAccess)
|
||||
assert.Empty(t, ordersGrant.ClientPermissionIDs)
|
||||
assert.False(t, ordersGrant.ClientAccess)
|
||||
|
||||
// Each client is listed once with only the permissions it holds on this API, ordered by name.
|
||||
_, err = svc.SetAPIClientAccess(t.Context(), orders.ID, "client-2", APIClientGrant{ClientPermissionIDs: []string{writeID}})
|
||||
require.NoError(t, err)
|
||||
clients, _, err = svc.ListAPIClients(t.Context(), orders.ID, "", utils.ListRequestOptions{})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, clients, 2)
|
||||
assert.Equal(t, "client-2", clients[0].Client.ID)
|
||||
assert.Empty(t, clients[0].UserDelegatedPermissionIDs)
|
||||
assert.ElementsMatch(t, []string{writeID}, clients[0].ClientPermissionIDs)
|
||||
assert.Equal(t, "client-1", clients[1].Client.ID)
|
||||
assert.ElementsMatch(t, []string{readID, writeID}, clients[1].UserDelegatedPermissionIDs)
|
||||
|
||||
// A client granted access without a single permission still shows up as having access to the API.
|
||||
_, err = svc.SetAPIClientAccess(t.Context(), billing.ID, "client-2", APIClientGrant{UserDelegatedAccess: true})
|
||||
require.NoError(t, err)
|
||||
billingClients, _, err := svc.ListAPIClients(t.Context(), billing.ID, "", utils.ListRequestOptions{})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, billingClients, 2)
|
||||
assert.Equal(t, "client-2", billingClients[0].Client.ID)
|
||||
assert.True(t, billingClients[0].UserDelegatedAccess)
|
||||
assert.False(t, billingClients[0].ClientAccess)
|
||||
assert.Empty(t, billingClients[0].UserDelegatedPermissionIDs)
|
||||
|
||||
// Revoking access to one API keeps the grants of the other one.
|
||||
require.NoError(t, svc.RemoveAPIClientAccess(t.Context(), orders.ID, "client-1"))
|
||||
remaining, err := svc.ListClientAPIs(t.Context(), "client-1")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, remaining, 1)
|
||||
assert.Equal(t, billing.ID, remaining[0].API.ID)
|
||||
assert.ElementsMatch(t, []string{invoicesID}, remaining[0].UserDelegatedPermissionIDs)
|
||||
|
||||
// The client's side of the relation lists the same grants, one row per API it may reach.
|
||||
clientAPIs, err := svc.ListClientAPIs(t.Context(), "client-2")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, clientAPIs, 2)
|
||||
assert.Equal(t, billing.ID, clientAPIs[0].API.ID)
|
||||
assert.True(t, clientAPIs[0].UserDelegatedAccess)
|
||||
assert.Empty(t, clientAPIs[0].UserDelegatedPermissionIDs)
|
||||
assert.Equal(t, orders.ID, clientAPIs[1].API.ID)
|
||||
assert.Len(t, clientAPIs[1].API.Permissions, 2)
|
||||
assert.True(t, clientAPIs[1].ClientAccess)
|
||||
assert.ElementsMatch(t, []string{writeID}, clientAPIs[1].ClientPermissionIDs)
|
||||
|
||||
// The selections an admin picks from only offer what is not granted yet, so pagination reflects what can still be added.
|
||||
assignableClients, pagination, err := svc.ListAssignableClients(t.Context(), orders.ID, "", utils.ListRequestOptions{})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, assignableClients, 1)
|
||||
assert.Equal(t, "client-1", assignableClients[0].ID)
|
||||
assert.Equal(t, int64(1), pagination.TotalItems)
|
||||
|
||||
assignableAPIs, _, err := svc.ListAssignableAPIs(t.Context(), "client-2", "", utils.ListRequestOptions{})
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, assignableAPIs)
|
||||
assignableAPIs, _, err = svc.ListAssignableAPIs(t.Context(), "client-1", "", utils.ListRequestOptions{})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, assignableAPIs, 1)
|
||||
assert.Equal(t, orders.ID, assignableAPIs[0].ID)
|
||||
|
||||
// An unknown API or client is rejected (surfaces as 404 at the HTTP layer).
|
||||
_, _, err = svc.ListAPIClients(t.Context(), "nope", "", utils.ListRequestOptions{})
|
||||
require.True(t, apperror.IsCode(err, apperror.CodeNotFound))
|
||||
_, err = svc.SetAPIClientAccess(t.Context(), "nope", "client-1", APIClientGrant{})
|
||||
require.True(t, apperror.IsCode(err, apperror.CodeNotFound))
|
||||
_, err = svc.SetAPIClientAccess(t.Context(), orders.ID, "nope", APIClientGrant{})
|
||||
require.True(t, apperror.IsCode(err, apperror.CodeNotFound))
|
||||
require.True(t, apperror.IsCode(svc.RemoveAPIClientAccess(t.Context(), "nope", "client-1"), apperror.CodeNotFound))
|
||||
_, err = svc.ListClientAPIs(t.Context(), "nope")
|
||||
require.True(t, apperror.IsCode(err, apperror.CodeNotFound))
|
||||
_, _, err = svc.ListAssignableClients(t.Context(), "nope", "", utils.ListRequestOptions{})
|
||||
require.True(t, apperror.IsCode(err, apperror.CodeNotFound))
|
||||
_, _, err = svc.ListAssignableAPIs(t.Context(), "nope", "", utils.ListRequestOptions{})
|
||||
require.True(t, apperror.IsCode(err, apperror.CodeNotFound))
|
||||
}
|
||||
|
||||
func findPermission(api API, key string) *Permission {
|
||||
for i := range api.Permissions {
|
||||
if api.Permissions[i].Key == key {
|
||||
|
||||
@@ -30,9 +30,10 @@ func isStandardScope(scope string) bool {
|
||||
// It lets the OIDC module widen per-client scope and audience validation and resolve RFC 8707 resources to the permission keys a client may be granted
|
||||
type APIAccessProvider interface {
|
||||
// ClientAPIScopes returns the custom-API permission keys and the distinct API audiences a client is allowed to request across all subject types
|
||||
ClientAPIScopes(ctx context.Context, tx *gorm.DB, clientID string) (scopes []string, audiences []string, err error)
|
||||
// AllowedScopesForAudience returns the permission keys the client is allowed for the API identified by the given audience and subject type, and whether such an API exists
|
||||
AllowedScopesForAudience(ctx context.Context, tx *gorm.DB, clientID, audience string, subjectType SubjectType) (scopes []string, apiExists bool, err error)
|
||||
ClientAPIScopes(ctx context.Context, tx *gorm.DB, clientID string, isCIMDClient bool) (scopes []string, audiences []string, err error)
|
||||
// AllowedScopesForAudience returns the permission keys the client is allowed for the API identified by the given audience and subject type, whether such an API exists, and whether the client may access it at all
|
||||
// A client can have access without any permission, in which case scopes is empty but hasAccess is true
|
||||
AllowedScopesForAudience(ctx context.Context, tx *gorm.DB, clientID, audience string, subjectType SubjectType) (scopes []string, apiExists bool, hasAccess bool, err error)
|
||||
// DescribePermissions returns the display information for the given permission keys of the API identified by audience
|
||||
// Unknown keys are omitted
|
||||
DescribePermissions(ctx context.Context, audience string, keys []string) ([]dto.ScopeInfoDto, error)
|
||||
@@ -57,14 +58,15 @@ func resolveResource(ctx context.Context, tx *gorm.DB, provider APIAccessProvide
|
||||
// Resolve every trailing-slash variant against the same canonical resource and stamp that value into the token audience
|
||||
resource = strings.TrimRight(resource, "/")
|
||||
|
||||
allowedScopes, apiExists, err := provider.AllowedScopesForAudience(ctx, tx, clientID, resource, subjectType)
|
||||
allowedScopes, apiExists, hasAccess, err := provider.AllowedScopesForAudience(ctx, tx, clientID, resource, subjectType)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if !apiExists {
|
||||
return "", nil, fosite.ErrInvalidTarget.WithHintf("The requested resource '%s' is invalid, missing, unknown, or malformed.", resource)
|
||||
}
|
||||
if len(allowedScopes) == 0 {
|
||||
// Access is per API: a client allowed to reach one without any permission still gets a token, only without a scope
|
||||
if !hasAccess {
|
||||
return "", nil, fosite.ErrAccessDenied.WithHintf("The OAuth 2.0 Client is not allowed to access resource '%s'.", resource)
|
||||
}
|
||||
|
||||
@@ -118,3 +120,18 @@ func consentScopeKeys(audience string, scopes []string) []string {
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// consentAudienceKey records consent to reach the API itself, independent of any scope, so an audience-only request still prompts instead of reusing a plain login's consent
|
||||
// The trailing unit separator with an empty scope cannot collide with a real key because permission keys are non-empty
|
||||
func consentAudienceKey(audience string) string {
|
||||
return audience + "\x1f"
|
||||
}
|
||||
|
||||
// consentKeysForGrant is the full set of keys a resolved grant has to be consented to: one per granted scope, plus the audience itself when a custom API was targeted
|
||||
func consentKeysForGrant(audience, resource string, grantedScopes []string) []string {
|
||||
keys := consentScopeKeys(audience, grantedScopes)
|
||||
if resource != "" {
|
||||
keys = append(keys, consentAudienceKey(audience))
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
)
|
||||
|
||||
// fakeAPIAccess implements APIAccessProvider from an audience -> subject type -> allowed-scopes map.
|
||||
// An audience present in the map exists as an API even when a subject type has no grants.
|
||||
// An audience present in the map exists as an API; a subject type present under it grants access, together with the scopes that subject may request, which can be none.
|
||||
type fakeAPIAccess struct {
|
||||
allowed map[string]map[SubjectType][]string
|
||||
}
|
||||
@@ -27,7 +27,7 @@ func userAccess(allowed map[string][]string) fakeAPIAccess {
|
||||
return f
|
||||
}
|
||||
|
||||
func (f fakeAPIAccess) ClientAPIScopes(_ context.Context, _ *gorm.DB, _ string) ([]string, []string, error) {
|
||||
func (f fakeAPIAccess) ClientAPIScopes(_ context.Context, _ *gorm.DB, _ string, _ bool) ([]string, []string, error) {
|
||||
seen := map[string]struct{}{}
|
||||
var scopes, audiences []string
|
||||
for audience, bySubject := range f.allowed {
|
||||
@@ -44,12 +44,13 @@ func (f fakeAPIAccess) ClientAPIScopes(_ context.Context, _ *gorm.DB, _ string)
|
||||
return scopes, audiences, nil
|
||||
}
|
||||
|
||||
func (f fakeAPIAccess) AllowedScopesForAudience(_ context.Context, _ *gorm.DB, _ string, audience string, subjectType SubjectType) ([]string, bool, error) {
|
||||
func (f fakeAPIAccess) AllowedScopesForAudience(_ context.Context, _ *gorm.DB, _ string, audience string, subjectType SubjectType) ([]string, bool, bool, error) {
|
||||
bySubject, exists := f.allowed[audience]
|
||||
if !exists {
|
||||
return nil, false, nil
|
||||
return nil, false, false, nil
|
||||
}
|
||||
return bySubject[subjectType], true, nil
|
||||
scopes, hasAccess := bySubject[subjectType]
|
||||
return scopes, true, hasAccess, nil
|
||||
}
|
||||
|
||||
func (f fakeAPIAccess) DescribePermissions(_ context.Context, audience string, keys []string) ([]dto.ScopeInfoDto, error) {
|
||||
@@ -122,11 +123,33 @@ func TestResolveResourceUnknownIsRejected(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestResolveResourceUnauthorizedClientIsRejected(t *testing.T) {
|
||||
// The API exists but the client has no allowed permissions for it.
|
||||
// The API exists but the client was not granted access to it for any subject type.
|
||||
provider := fakeAPIAccess{allowed: map[string]map[SubjectType][]string{
|
||||
"https://api.orders.example.com": {},
|
||||
}}
|
||||
_, _, err := resolveResource(t.Context(), nil, provider, "client-1", "https://api.orders.example.com", []string{"read:orders"}, SubjectTypeUser)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
// TestResolveResourceAccessWithoutPermissions covers a client that may reach an API without holding a single
|
||||
// permission, which is what the MCP specification expects: the token is audienced to the resource and carries no scope.
|
||||
func TestResolveResourceAccessWithoutPermissions(t *testing.T) {
|
||||
provider := userAccess(map[string][]string{
|
||||
"https://api.orders.example.com": {},
|
||||
})
|
||||
_, _, err := resolveResource(t.Context(), nil, provider, "client-1", "https://api.orders.example.com", []string{"read:orders"}, SubjectTypeUser)
|
||||
|
||||
audience, granted, err := resolveResource(t.Context(), nil, provider, "client-1", "https://api.orders.example.com", nil, SubjectTypeUser)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "https://api.orders.example.com", audience)
|
||||
assert.Empty(t, granted)
|
||||
|
||||
// Identity scopes still ride along, so the same request can also produce an ID token
|
||||
_, granted, err = resolveResource(t.Context(), nil, provider, "client-1", "https://api.orders.example.com", []string{"openid"}, SubjectTypeUser)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"openid"}, granted)
|
||||
|
||||
// Access alone does not make a custom scope requestable
|
||||
_, _, err = resolveResource(t.Context(), nil, provider, "client-1", "https://api.orders.example.com", []string{"read:orders"}, SubjectTypeUser)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
|
||||
@@ -136,6 +136,20 @@ func (h *authorizationHandler) completeInteraction(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *authorizationHandler) writeAuthorizeError(ctx context.Context, c *gin.Context, ar fosite.AuthorizeRequester, err error) {
|
||||
if rfcErr, ok := errors.AsType[*fosite.RFC6749Error](err); ok {
|
||||
attrs := []slog.Attr{
|
||||
slog.String("error", rfcErr.ErrorField),
|
||||
slog.String("description", rfcErr.DescriptionField),
|
||||
slog.String("hint", rfcErr.Reason()),
|
||||
slog.String("debug", rfcErr.Debug()),
|
||||
slog.Int("status_code", rfcErr.StatusCode()),
|
||||
}
|
||||
if cause := rfcErr.Cause(); cause != nil {
|
||||
attrs = append(attrs, slog.String("cause", cause.Error()))
|
||||
}
|
||||
slog.LogAttrs(ctx, slog.LevelDebug, "Fosite authorize error details", attrs...)
|
||||
}
|
||||
|
||||
// Keep authorization policy denials in Pocket ID so users can see why access was refused
|
||||
if errors.Is(err, fosite.ErrAccessDenied) {
|
||||
h.redirectToInteractionError(c, err)
|
||||
|
||||
@@ -47,7 +47,8 @@ func (s *authorizationService) resolveGrant(ctx context.Context, clientID, resou
|
||||
if err != nil {
|
||||
return "", nil, nil, err
|
||||
}
|
||||
return audience, grantedScopes, consentScopeKeys(audience, grantedScopes), nil
|
||||
|
||||
return audience, grantedScopes, consentKeysForGrant(audience, resource, grantedScopes), nil
|
||||
}
|
||||
|
||||
type requestMeta struct {
|
||||
|
||||
@@ -118,8 +118,8 @@ func TestAuthorizationServiceCollapsesResourceErrorsBeforeAuthentication(t *test
|
||||
|
||||
// The targeted API does not exist at all
|
||||
unknownErr := authorizeWithResource(t, userAccess(map[string][]string{}), unknownAPI)
|
||||
// The targeted API exists but this client has been granted none of its permissions
|
||||
ungrantedErr := authorizeWithResource(t, userAccess(map[string][]string{knownAPI: {}}), knownAPI)
|
||||
// The targeted API exists but this client has not been granted access to it
|
||||
ungrantedErr := authorizeWithResource(t, fakeAPIAccess{allowed: map[string]map[SubjectType][]string{knownAPI: {}}}, knownAPI)
|
||||
|
||||
require.Error(t, unknownErr)
|
||||
require.Error(t, ungrantedErr)
|
||||
@@ -193,8 +193,8 @@ func TestAuthorizationServiceConsentMergesAudienceQualifiedScopeKeys(t *testing.
|
||||
Name: "Test Client",
|
||||
}).Error)
|
||||
|
||||
apiAConsent := consentScopeKeys(apiA, []string{"openid", "read"})
|
||||
apiBConsent := consentScopeKeys(apiB, []string{"openid", "read"})
|
||||
apiAConsent := consentKeysForGrant(apiA, apiA, []string{"openid", "read"})
|
||||
apiBConsent := consentKeysForGrant(apiB, apiB, []string{"openid", "read"})
|
||||
|
||||
hasAlreadyAuthorized, err := service.consent(t.Context(), userID, clientID, apiAConsent)
|
||||
require.NoError(t, err)
|
||||
@@ -206,7 +206,11 @@ func TestAuthorizationServiceConsentMergesAudienceQualifiedScopeKeys(t *testing.
|
||||
|
||||
var authorizedClient model.UserAuthorizedOidcClient
|
||||
require.NoError(t, db.First(&authorizedClient, "user_id = ? AND client_id = ?", userID, clientID).Error)
|
||||
require.ElementsMatch(t, []string{"openid", consentScopeKey(apiA, "read"), consentScopeKey(apiB, "read")}, authorizedClient.Scope)
|
||||
require.ElementsMatch(t, []string{
|
||||
"openid",
|
||||
consentScopeKey(apiA, "read"), consentAudienceKey(apiA),
|
||||
consentScopeKey(apiB, "read"), consentAudienceKey(apiB),
|
||||
}, authorizedClient.Scope)
|
||||
|
||||
hasAuthorizedAPIA, err := service.hasAuthorizedClient(t.Context(), clientID, userID, apiAConsent)
|
||||
require.NoError(t, err)
|
||||
@@ -233,6 +237,58 @@ func TestAuthorizationServiceConsentMergesAudienceQualifiedScopeKeys(t *testing.
|
||||
require.Equal(t, []model.AuditLogEvent{model.AuditLogEventClientAuthorization}, auditLogger.events)
|
||||
}
|
||||
|
||||
// TestAuthorizationServiceRequiresConsentForScopelessAPIAccess guards that a token audienced to a custom API always needs
|
||||
// its own consent, even when the grant carries no custom scope and the user already consented to a plain login.
|
||||
func TestAuthorizationServiceRequiresConsentForScopelessAPIAccess(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
|
||||
const (
|
||||
userID = "test-user"
|
||||
clientID = "test-client"
|
||||
api = "https://api-a.example.com"
|
||||
)
|
||||
|
||||
// The client may reach the API but was granted none of its permissions, which is the MCP-style scopeless grant
|
||||
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil, userAccess(map[string][]string{
|
||||
api: {},
|
||||
}))
|
||||
|
||||
require.NoError(t, db.Create(&model.User{Base: model.Base{ID: userID}}).Error)
|
||||
require.NoError(t, db.Create(&model.OidcClient{Base: model.Base{ID: clientID}, Name: "Test Client"}).Error)
|
||||
|
||||
// The user has already signed in to this client, so a bare identity consent is on record
|
||||
_, err := service.consent(t.Context(), userID, clientID, []string{"openid"})
|
||||
require.NoError(t, err)
|
||||
|
||||
// That consent must not carry over to a request audienced at the API
|
||||
form := url.Values{"prompt": {"none"}, "resource": {api}}
|
||||
requester := newTestAuthorizeRequesterWithForm("silent-api-request", clientID, form)
|
||||
requester.(*fosite.AuthorizeRequest).RequestedScope = fosite.Arguments{"openid"}
|
||||
|
||||
_, err = service.authorize(t.Context(), authorizeInput{
|
||||
userID: userID,
|
||||
authenticationTime: time.Now().UTC(),
|
||||
requester: requester,
|
||||
meta: requestMeta{IPAddress: "203.0.113.1", UserAgent: "test-agent"},
|
||||
})
|
||||
require.Error(t, err)
|
||||
|
||||
// Once the API access itself is consented to, the same silent request goes through
|
||||
_, err = service.consent(t.Context(), userID, clientID, consentKeysForGrant(api, api, []string{"openid"}))
|
||||
require.NoError(t, err)
|
||||
|
||||
requester = newTestAuthorizeRequesterWithForm("silent-api-request-2", clientID, form)
|
||||
requester.(*fosite.AuthorizeRequest).RequestedScope = fosite.Arguments{"openid"}
|
||||
authorization, err := service.authorize(t.Context(), authorizeInput{
|
||||
userID: userID,
|
||||
authenticationTime: time.Now().UTC(),
|
||||
requester: requester,
|
||||
meta: requestMeta{IPAddress: "203.0.113.1", UserAgent: "test-agent"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.False(t, authorization.RequiresInteraction)
|
||||
}
|
||||
|
||||
func TestAuthorizationServiceAuthorizeConsumesInteractionSession(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil, nil)
|
||||
|
||||
@@ -107,7 +107,7 @@ func (s *Store) GetClient(ctx context.Context, id string) (fosite.Client, error)
|
||||
|
||||
// Populate the custom-API scopes and audiences the client may request only when the API feature is wired
|
||||
if s.apiAccess != nil {
|
||||
apiScopes, apiAudiences, err := s.apiAccess.ClientAPIScopes(ctx, tx, id)
|
||||
apiScopes, apiAudiences, err := s.apiAccess.ClientAPIScopes(ctx, tx, id, clientModel.IsMetadataDocument())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -133,7 +133,7 @@ func (s *Store) clientFromModel(ctx context.Context, tx *gorm.DB, clientModel mo
|
||||
|
||||
// Populate the custom-API scopes and audiences the client may request only when the API feature is wired
|
||||
if s.apiAccess != nil {
|
||||
apiScopes, apiAudiences, err := s.apiAccess.ClientAPIScopes(ctx, tx, clientModel.ID)
|
||||
apiScopes, apiAudiences, err := s.apiAccess.ClientAPIScopes(ctx, tx, clientModel.ID, clientModel.IsMetadataDocument())
|
||||
if err != nil {
|
||||
return Client{}, err
|
||||
}
|
||||
|
||||
@@ -421,6 +421,25 @@ func (s *TestService) SeedDatabase(baseURL string) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Immich may reach the Orders API for both subject types, which is what the permission grants below build on
|
||||
allowedAPIs := []api.OidcClientAllowedAPI{
|
||||
{
|
||||
OidcClientID: oidcClients[1].ID,
|
||||
APIID: ordersAPI.ID,
|
||||
SubjectType: oidc.SubjectTypeUser,
|
||||
},
|
||||
{
|
||||
OidcClientID: oidcClients[1].ID,
|
||||
APIID: ordersAPI.ID,
|
||||
SubjectType: oidc.SubjectTypeClient,
|
||||
},
|
||||
}
|
||||
for _, allowed := range allowedAPIs {
|
||||
if err := tx.Create(&allowed).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Immich is allowed to request read:orders on behalf of users and to obtain write:orders for itself via the client credentials grant
|
||||
allowedAPIPermissions := []api.OidcClientAllowedAPIPermission{
|
||||
{
|
||||
|
||||
@@ -96,7 +96,7 @@ func extractDatabaseTx(tx *gorm.DB) (DatabaseExport, error) {
|
||||
Tables: map[string][]map[string]any{},
|
||||
// These tables need to be inserted in a specific order because of foreign key constraints
|
||||
// Not all tables are listed here, because not all tables are order-dependent
|
||||
TableOrder: []string{"users", "user_groups", "oidc_clients", "oauth2_sessions", "signup_tokens", "apis", "api_permissions", "oidc_clients_allowed_api_permissions"},
|
||||
TableOrder: []string{"users", "user_groups", "oidc_clients", "oauth2_sessions", "signup_tokens", "apis", "api_permissions", "oidc_clients_allowed_apis", "oidc_clients_allowed_api_permissions"},
|
||||
}
|
||||
|
||||
for table := range schema {
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
DROP TABLE IF EXISTS oidc_clients_allowed_apis;
|
||||
ALTER TABLE api_permissions DROP COLUMN allowed_for_cimd_clients;
|
||||
ALTER TABLE apis DROP COLUMN allow_cimd_clients;
|
||||
@@ -0,0 +1,22 @@
|
||||
-- Keep existing API audiences aligned with the canonical resource identifiers used by new APIs and OAuth resource resolution
|
||||
UPDATE apis SET audience = RTRIM(audience, '/') WHERE audience <> RTRIM(audience, '/');
|
||||
|
||||
ALTER TABLE apis ADD COLUMN allow_cimd_clients BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
ALTER TABLE api_permissions ADD COLUMN allowed_for_cimd_clients BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
-- Access to an API is tracked separately from the permissions granted for it, because a client may be allowed to request tokens for an API without any scope
|
||||
CREATE TABLE oidc_clients_allowed_apis (
|
||||
oidc_client_id TEXT NOT NULL REFERENCES oidc_clients(id) ON DELETE CASCADE,
|
||||
api_id UUID NOT NULL REFERENCES apis(id) ON DELETE CASCADE,
|
||||
subject_type TEXT NOT NULL CHECK (subject_type IN ('user', 'client')),
|
||||
PRIMARY KEY (oidc_client_id, api_id, subject_type)
|
||||
);
|
||||
|
||||
-- The primary key leads with oidc_client_id, so the API-side lookups and the cascade from apis need their own index
|
||||
CREATE INDEX idx_oidc_clients_allowed_apis_api_id ON oidc_clients_allowed_apis(api_id);
|
||||
|
||||
-- Every existing permission grant implied access to its API, so those clients keep exactly the access they had
|
||||
INSERT INTO oidc_clients_allowed_apis (oidc_client_id, api_id, subject_type)
|
||||
SELECT DISTINCT g.oidc_client_id, p.api_id, g.subject_type
|
||||
FROM oidc_clients_allowed_api_permissions g
|
||||
JOIN api_permissions p ON p.id = g.api_permission_id;
|
||||
@@ -0,0 +1,9 @@
|
||||
PRAGMA foreign_keys=OFF;
|
||||
BEGIN;
|
||||
|
||||
DROP TABLE IF EXISTS oidc_clients_allowed_apis;
|
||||
ALTER TABLE api_permissions DROP COLUMN allowed_for_cimd_clients;
|
||||
ALTER TABLE apis DROP COLUMN allow_cimd_clients;
|
||||
|
||||
COMMIT;
|
||||
PRAGMA foreign_keys=ON;
|
||||
@@ -0,0 +1,28 @@
|
||||
PRAGMA foreign_keys=OFF;
|
||||
BEGIN;
|
||||
|
||||
-- Keep existing API audiences aligned with the canonical resource identifiers used by new APIs and OAuth resource resolution
|
||||
UPDATE apis SET audience = RTRIM(audience, '/') WHERE audience <> RTRIM(audience, '/');
|
||||
|
||||
ALTER TABLE apis ADD COLUMN allow_cimd_clients BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
ALTER TABLE api_permissions ADD COLUMN allowed_for_cimd_clients BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
-- Access to an API is tracked separately from the permissions granted for it, because a client may be allowed to request tokens for an API without any scope
|
||||
CREATE TABLE oidc_clients_allowed_apis (
|
||||
oidc_client_id TEXT NOT NULL REFERENCES oidc_clients(id) ON DELETE CASCADE,
|
||||
api_id TEXT NOT NULL REFERENCES apis(id) ON DELETE CASCADE,
|
||||
subject_type TEXT NOT NULL CHECK (subject_type IN ('user', 'client')),
|
||||
PRIMARY KEY (oidc_client_id, api_id, subject_type)
|
||||
);
|
||||
|
||||
-- The primary key leads with oidc_client_id, so the API-side lookups and the cascade from apis need their own index
|
||||
CREATE INDEX idx_oidc_clients_allowed_apis_api_id ON oidc_clients_allowed_apis(api_id);
|
||||
|
||||
-- Every existing permission grant implied access to its API, so those clients keep exactly the access they had
|
||||
INSERT INTO oidc_clients_allowed_apis (oidc_client_id, api_id, subject_type)
|
||||
SELECT DISTINCT g.oidc_client_id, p.api_id, g.subject_type
|
||||
FROM oidc_clients_allowed_api_permissions g
|
||||
JOIN api_permissions p ON p.id = g.api_permission_id;
|
||||
|
||||
COMMIT;
|
||||
PRAGMA foreign_keys=ON;
|
||||
@@ -586,7 +586,7 @@
|
||||
"api_permissions_updated_successfully": "Permissions updated successfully",
|
||||
"are_you_sure_you_want_to_delete_this_api": "Are you sure you want to delete this API? Clients will lose access to its permissions.",
|
||||
"api_access": "API access",
|
||||
"api_access_description": "Select which API permissions this client may request on behalf of users (user-delegated access) and for itself via the client credentials grant (client access).",
|
||||
"api_access_description": "Select which APIs this client may request tokens for on behalf of users (user-delegated access) and for itself via the client credentials grant (client access), and which permissions it may ask for.",
|
||||
"api_access_updated_successfully": "API access updated successfully",
|
||||
"no_apis_defined_yet": "No APIs have been defined yet. APIs allow clients to request access tokens for specific resources and permissions.",
|
||||
"access_an_api_on_your_behalf": "Access an API on your behalf",
|
||||
@@ -595,7 +595,30 @@
|
||||
"client_access": "Client access (M2M)",
|
||||
"client_access_unavailable_for_public_clients": "Public clients can't use the client credentials grant, so client access is not available.",
|
||||
"permissions_granted_count": "{granted} / {total} permissions granted",
|
||||
"select_the_permissions_this_client_may_request": "Select the permissions this client may request on behalf of the signed-in user (user-delegated access) and for itself without a user via the client credentials grant (client access).",
|
||||
"select_the_access_this_client_may_request": "Choose whether this client may request tokens for this API on behalf of the signed-in user (user-delegated access) and for itself without a user via the client credentials grant (client access), and which permissions it may ask for.",
|
||||
"user_delegated_access_description": "The client may request tokens for this API on behalf of the signed-in user.",
|
||||
"client_access_description": "The client may request tokens for this API for itself, without a user.",
|
||||
"no_access": "No access",
|
||||
"metadata_document_client_access": "Metadata document clients",
|
||||
"metadata_document_client_access_description": "Grant access to clients that register themselves through a Client ID Metadata Document (CIMD), such as MCP servers.",
|
||||
"allow_all_metadata_document_clients": "Allow all metadata document clients",
|
||||
"allow_all_metadata_document_clients_description": "Every client registered through a metadata document can request tokens for this API on behalf of users.",
|
||||
"granted_permissions": "Granted permissions",
|
||||
"granted_through_cimd_access": "Granted because this API allows all metadata document clients",
|
||||
"access_granted_through_cimd_access": "This API allows all metadata document clients, so the access granted that way is shown here but can only be changed on the API itself.",
|
||||
"access": "Access",
|
||||
"api_access_card_description": "Choose which clients may request tokens for this API and which permissions they may ask for.",
|
||||
"api_clients_description": "The clients that were granted access to this API individually.",
|
||||
"no_clients_have_access_to_this_api": "No client has been granted access to this API yet.",
|
||||
"add_client": "Add client",
|
||||
"select_a_client_to_grant_access_to_this_api": "Select the client that should get access to this API.",
|
||||
"revoke_access_for_name": "Revoke access for {name}",
|
||||
"are_you_sure_you_want_to_revoke_the_api_access_of_this_client": "Are you sure you want to revoke this client's access to the API? Its permissions on other APIs are kept.",
|
||||
"revoke_access_to_name": "Revoke access to {name}",
|
||||
"are_you_sure_you_want_to_revoke_the_access_of_this_client_to_the_api": "Are you sure you want to revoke this client's access to the API? Its access to other APIs is kept.",
|
||||
"this_client_has_no_api_access_yet": "This client has not been granted access to any API yet.",
|
||||
"select_an_api_this_client_should_access": "Select the API this client should get access to.",
|
||||
"granted_to_all_cimd_clients": "Granted to all metadata document clients",
|
||||
"i_have_a_longer_code": "I have a longer code",
|
||||
"pkce_supported_client_title": "This client supports PKCE",
|
||||
"pkce_supported_client_description": "This client supports Proof Key for Code Exchange (PKCE). PKCE is a security feature that helps protect against certain attacks during the OAuth 2.0 authorization process. It's recommended to enable it.",
|
||||
|
||||
21
frontend/src/lib/components/api-access-cell.svelte
Normal file
21
frontend/src/lib/components/api-access-cell.svelte
Normal file
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
|
||||
let {
|
||||
hasAccess,
|
||||
granted,
|
||||
total
|
||||
}: {
|
||||
hasAccess: boolean;
|
||||
granted: number;
|
||||
total: number;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
{#if !hasAccess}
|
||||
<span class="text-muted-foreground text-sm">{m.no_access()}</span>
|
||||
{:else}
|
||||
<span class="text-sm">
|
||||
{m.permissions_granted_count({ granted: String(granted), total: String(total) })}
|
||||
</span>
|
||||
{/if}
|
||||
@@ -1,38 +1,47 @@
|
||||
<script lang="ts">
|
||||
import SwitchWithLabel from '$lib/components/form/switch-with-label.svelte';
|
||||
import AdvancedTable from '$lib/components/table/advanced-table.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import Checkbox from '$lib/components/ui/checkbox/checkbox.svelte';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import type { AdvancedTableColumn } from '$lib/types/advanced-table.type';
|
||||
import type { Api, ApiPermission } from '$lib/types/api.type';
|
||||
import type { Api, ApiClientGrant, ApiPermission } from '$lib/types/api.type';
|
||||
import type { ListRequestOptions, Paginated } from '$lib/types/list-request.type';
|
||||
import { axiosErrorToast } from '$lib/utils/error-util';
|
||||
|
||||
let {
|
||||
open = $bindable(),
|
||||
api,
|
||||
userAllowedIds,
|
||||
clientAllowedIds,
|
||||
grant,
|
||||
implicitUserAccess = false,
|
||||
implicitUserIds = [],
|
||||
showClientAccess,
|
||||
title,
|
||||
onSave
|
||||
}: {
|
||||
open: boolean;
|
||||
api: Api;
|
||||
userAllowedIds: string[];
|
||||
clientAllowedIds: string[];
|
||||
grant: ApiClientGrant;
|
||||
implicitUserAccess?: boolean;
|
||||
implicitUserIds?: string[];
|
||||
showClientAccess: boolean;
|
||||
onSave: (userPermissionIds: string[], clientPermissionIds: string[]) => Promise<void>;
|
||||
title?: string;
|
||||
onSave: (grant: ApiClientGrant) => Promise<void>;
|
||||
} = $props();
|
||||
|
||||
let workingUserAccess = $state(false);
|
||||
let workingClientAccess = $state(false);
|
||||
let workingUser = $state<string[]>([]);
|
||||
let workingClient = $state<string[]>([]);
|
||||
let saving = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
workingUser = [...userAllowedIds];
|
||||
workingClient = [...clientAllowedIds];
|
||||
workingUserAccess = grant.userDelegatedAccess;
|
||||
workingClientAccess = grant.clientAccess;
|
||||
workingUser = [...grant.userDelegatedPermissionIds];
|
||||
workingClient = [...grant.clientPermissionIds];
|
||||
}
|
||||
});
|
||||
|
||||
@@ -53,6 +62,21 @@
|
||||
return ids.filter((existing) => existing !== id);
|
||||
}
|
||||
|
||||
// A permission only makes sense together with access to the API, so checking one turns the access on and turning access off drops the selection
|
||||
function toggleUserPermission(id: string, checked: boolean) {
|
||||
workingUser = toggle(workingUser, id, checked);
|
||||
if (checked) {
|
||||
workingUserAccess = true;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleClientPermission(id: string, checked: boolean) {
|
||||
workingClient = toggle(workingClient, id, checked);
|
||||
if (checked) {
|
||||
workingClientAccess = true;
|
||||
}
|
||||
}
|
||||
|
||||
function fetchCallback(options: ListRequestOptions): Promise<Paginated<ApiPermission>> {
|
||||
let data = api.permissions;
|
||||
|
||||
@@ -95,7 +119,12 @@
|
||||
async function save() {
|
||||
saving = true;
|
||||
try {
|
||||
await onSave(workingUser, workingClient);
|
||||
await onSave({
|
||||
userDelegatedAccess: workingUserAccess,
|
||||
clientAccess: showClientAccess && workingClientAccess,
|
||||
userDelegatedPermissionIds: workingUserAccess ? workingUser : [],
|
||||
clientPermissionIds: showClientAccess && workingClientAccess ? workingClient : []
|
||||
});
|
||||
open = false;
|
||||
} catch (e) {
|
||||
axiosErrorToast(e);
|
||||
@@ -110,10 +139,13 @@
|
||||
{/snippet}
|
||||
|
||||
{#snippet UserDelegatedCell({ item }: { item: ApiPermission })}
|
||||
{@const implicit = implicitUserIds.includes(item.id)}
|
||||
<Checkbox
|
||||
aria-label={`${m.user_delegated_access()}: ${item.name}`}
|
||||
checked={workingUser.includes(item.id)}
|
||||
onCheckedChange={(checked: boolean) => (workingUser = toggle(workingUser, item.id, checked))}
|
||||
checked={implicit || workingUser.includes(item.id)}
|
||||
disabled={implicit}
|
||||
title={implicit ? m.granted_through_cimd_access() : undefined}
|
||||
onCheckedChange={(checked: boolean) => toggleUserPermission(item.id, checked)}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
@@ -121,29 +153,64 @@
|
||||
<Checkbox
|
||||
aria-label={`${m.client_access()}: ${item.name}`}
|
||||
checked={workingClient.includes(item.id)}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
(workingClient = toggle(workingClient, item.id, checked))}
|
||||
onCheckedChange={(checked: boolean) => toggleClientPermission(item.id, checked)}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="max-h-[90vh] min-w-[90vw] overflow-auto lg:min-w-250">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{api.name}</Dialog.Title>
|
||||
<Dialog.Title>{title ?? api.name}</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
{m.select_the_permissions_this_client_may_request()}
|
||||
{m.select_the_access_this_client_may_request()}
|
||||
{#if !showClientAccess}
|
||||
{m.client_access_unavailable_for_public_clients()}
|
||||
{/if}
|
||||
{#if implicitUserAccess}
|
||||
{m.access_granted_through_cimd_access()}
|
||||
{/if}
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<AdvancedTable
|
||||
id={`api-access-grants-${api.id}`}
|
||||
{columns}
|
||||
{fetchCallback}
|
||||
defaultSort={{ column: 'name', direction: 'asc' }}
|
||||
/>
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:gap-10">
|
||||
<SwitchWithLabel
|
||||
id={`api-user-access-${api.id}`}
|
||||
label={m.user_delegated_access()}
|
||||
description={m.user_delegated_access_description()}
|
||||
checked={implicitUserAccess || workingUserAccess}
|
||||
disabled={implicitUserAccess}
|
||||
onCheckedChange={(checked) => {
|
||||
workingUserAccess = checked;
|
||||
if (!checked) {
|
||||
workingUser = [];
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{#if showClientAccess}
|
||||
<SwitchWithLabel
|
||||
id={`api-client-access-${api.id}`}
|
||||
label={m.client_access()}
|
||||
description={m.client_access_description()}
|
||||
bind:checked={workingClientAccess}
|
||||
onCheckedChange={(checked) => {
|
||||
if (!checked) {
|
||||
workingClient = [];
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if api.permissions.length > 0}
|
||||
<div class="overflow-auto">
|
||||
<AdvancedTable
|
||||
id={`api-access-grants-${api.id}`}
|
||||
{columns}
|
||||
{fetchCallback}
|
||||
defaultSort={{ column: 'name', direction: 'asc' }}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-4 flex justify-end gap-2">
|
||||
<Button variant="secondary" onclick={() => (open = false)}>{m.cancel()}</Button>
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { Switch } from '$lib/components/ui/switch/index.js';
|
||||
import { cn } from '$lib/utils/style';
|
||||
|
||||
let {
|
||||
id,
|
||||
@@ -8,6 +9,7 @@
|
||||
label,
|
||||
description,
|
||||
disabled = false,
|
||||
class: className,
|
||||
onCheckedChange
|
||||
}: {
|
||||
id: string;
|
||||
@@ -15,11 +17,12 @@
|
||||
label: string;
|
||||
description?: string;
|
||||
disabled?: boolean;
|
||||
class?: string;
|
||||
onCheckedChange?: (checked: boolean) => void;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div class="items-top flex space-x-2">
|
||||
<div class={cn('items-top flex space-x-2', className)}>
|
||||
<Switch
|
||||
{id}
|
||||
{disabled}
|
||||
|
||||
35
frontend/src/lib/components/oidc-client-avatar.svelte
Normal file
35
frontend/src/lib/components/oidc-client-avatar.svelte
Normal file
@@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import ImageBox from '$lib/components/image-box.svelte';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import { cachedOidcClientLogo } from '$lib/utils/cached-image-util';
|
||||
import { cn } from '$lib/utils/style';
|
||||
import { mode } from 'mode-watcher';
|
||||
|
||||
let {
|
||||
id,
|
||||
name,
|
||||
hasLogo,
|
||||
class: className = 'size-9'
|
||||
}: {
|
||||
id: string;
|
||||
name: string;
|
||||
hasLogo: boolean;
|
||||
class?: string;
|
||||
} = $props();
|
||||
|
||||
const isLightMode = $derived(mode.current === 'light');
|
||||
</script>
|
||||
|
||||
{#if hasLogo}
|
||||
<ImageBox
|
||||
class={cn('rounded-lg', className)}
|
||||
src={cachedOidcClientLogo.getUrl(id, isLightMode)}
|
||||
alt={m.name_logo({ name })}
|
||||
/>
|
||||
{:else}
|
||||
<div
|
||||
class={cn('bg-muted flex shrink-0 items-center justify-center rounded-lg font-bold', className)}
|
||||
>
|
||||
{name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -27,6 +27,7 @@
|
||||
<DropdownMenu.Label>{m.toggle_columns()}</DropdownMenu.Label>
|
||||
<DropdownMenu.Separator />
|
||||
{#each columns as column (column)}
|
||||
{#if column.label}
|
||||
<DropdownMenu.CheckboxItem
|
||||
closeOnSelect={false}
|
||||
checked={selectedColumns.includes(column.column ?? column.key!)}
|
||||
@@ -41,6 +42,7 @@
|
||||
>
|
||||
{column.label}
|
||||
</DropdownMenu.CheckboxItem>
|
||||
{/if}
|
||||
{/each}
|
||||
</DropdownMenu.Group>
|
||||
</DropdownMenu.Content>
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
selectedIds = $bindable(),
|
||||
withoutSearch = false,
|
||||
selectionDisabled = false,
|
||||
onRowClick,
|
||||
rowSelectionDisabled,
|
||||
fetchCallback,
|
||||
defaultSort,
|
||||
@@ -39,6 +40,7 @@
|
||||
fetchCallback: (requestOptions: ListRequestOptions) => Promise<Paginated<T>>;
|
||||
defaultSort?: SortRequest;
|
||||
columns: AdvancedTableColumn<T>[];
|
||||
onRowClick?: (item: T) => void;
|
||||
actions?: CreateAdvancedTableActions<T>;
|
||||
} = $props();
|
||||
|
||||
@@ -270,9 +272,9 @@
|
||||
<Table.Row
|
||||
class={{
|
||||
'bg-muted/20': selectedIds?.includes(item.id),
|
||||
'cursor-pointer': getPrimaryAction(item)
|
||||
'cursor-pointer': getPrimaryAction(item) || onRowClick
|
||||
}}
|
||||
onclick={getPrimaryAction(item)}
|
||||
onclick={onRowClick ? () => onRowClick(item) : getPrimaryAction(item)}
|
||||
>
|
||||
{#if selectedIds}
|
||||
<Table.Cell class="w-12">
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import type {
|
||||
Api,
|
||||
ApiCimdAccessUpdate,
|
||||
ApiClient,
|
||||
ApiClientAccess,
|
||||
ApiClientGrant,
|
||||
ApiCreate,
|
||||
ApiPermissionInput,
|
||||
ApiUpdate,
|
||||
ClientApiAccess
|
||||
ClientApiGrant
|
||||
} from '$lib/types/api.type';
|
||||
import type { ListRequestOptions, Paginated } from '$lib/types/list-request.type';
|
||||
import { encodeClientIdParam } from '$lib/utils/client-id-util';
|
||||
@@ -44,13 +48,39 @@ export default class ApisService extends APIService {
|
||||
return res.data as Api;
|
||||
};
|
||||
|
||||
getClientAccess = async (clientId: string) => {
|
||||
const res = await this.api.get(`/api-access/${encodeClientIdParam(clientId)}`);
|
||||
return res.data as ClientApiAccess;
|
||||
updateCimdAccess = async (id: string, access: ApiCimdAccessUpdate) => {
|
||||
const res = await this.api.put(`/apis/${id}/cimd-access`, access);
|
||||
return res.data as Api;
|
||||
};
|
||||
|
||||
updateClientAccess = async (clientId: string, access: ClientApiAccess) => {
|
||||
const res = await this.api.put(`/api-access/${encodeClientIdParam(clientId)}`, access);
|
||||
return res.data as ClientApiAccess;
|
||||
listClients = async (id: string, options?: ListRequestOptions) => {
|
||||
const res = await this.api.get(`/apis/${id}/clients`, { params: options });
|
||||
return res.data as Paginated<ApiClientAccess>;
|
||||
};
|
||||
|
||||
listAssignableClients = async (id: string, options?: ListRequestOptions) => {
|
||||
const res = await this.api.get(`/apis/${id}/assignable-clients`, { params: options });
|
||||
return res.data as Paginated<ApiClient>;
|
||||
};
|
||||
|
||||
updateClientAccessForApi = async (id: string, clientId: string, grant: ApiClientGrant) => {
|
||||
const res = await this.api.put(`/apis/${id}/clients/${encodeClientIdParam(clientId)}`, grant);
|
||||
return res.data as ApiClientGrant;
|
||||
};
|
||||
|
||||
removeClientAccessForApi = async (id: string, clientId: string) => {
|
||||
await this.api.delete(`/apis/${id}/clients/${encodeClientIdParam(clientId)}`);
|
||||
};
|
||||
|
||||
listClientApis = async (clientId: string) => {
|
||||
const res = await this.api.get(`/api-access/${encodeClientIdParam(clientId)}/apis`);
|
||||
return res.data as ClientApiGrant[];
|
||||
};
|
||||
|
||||
listAssignableApis = async (clientId: string, options?: ListRequestOptions) => {
|
||||
const res = await this.api.get(`/api-access/${encodeClientIdParam(clientId)}/assignable-apis`, {
|
||||
params: options
|
||||
});
|
||||
return res.data as Paginated<Api>;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ export type ApiPermission = {
|
||||
key: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
allowedForCimdClients: boolean;
|
||||
};
|
||||
|
||||
export type Api = {
|
||||
@@ -11,6 +12,7 @@ export type Api = {
|
||||
resource: string;
|
||||
createdAt: string;
|
||||
permissions: ApiPermission[];
|
||||
allowCimdClients: boolean;
|
||||
};
|
||||
|
||||
export type ApiCreate = {
|
||||
@@ -28,7 +30,35 @@ export type ApiPermissionInput = {
|
||||
description: string;
|
||||
};
|
||||
|
||||
export type ClientApiAccess = {
|
||||
export type ApiClientGrant = {
|
||||
userDelegatedAccess: boolean;
|
||||
clientAccess: boolean;
|
||||
userDelegatedPermissionIds: string[];
|
||||
clientPermissionIds: string[];
|
||||
};
|
||||
|
||||
export type ApiCimdAccessUpdate = {
|
||||
enabled: boolean;
|
||||
permissionIds: string[];
|
||||
};
|
||||
|
||||
export type ApiClient = {
|
||||
id: string;
|
||||
name: string;
|
||||
clientType: string;
|
||||
isPublic: boolean;
|
||||
hasLogo: boolean;
|
||||
hasDarkLogo: boolean;
|
||||
};
|
||||
|
||||
export type ApiClientAccess = ApiClientGrant & {
|
||||
client: ApiClient;
|
||||
cimdGrantedAccess: boolean;
|
||||
cimdGrantedPermissionIds: string[];
|
||||
};
|
||||
|
||||
export type ClientApiGrant = ApiClientGrant & {
|
||||
api: Api;
|
||||
cimdGrantedAccess: boolean;
|
||||
cimdGrantedPermissionIds: string[];
|
||||
};
|
||||
|
||||
@@ -4,27 +4,38 @@
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import ApisService from '$lib/services/apis-service';
|
||||
import type { ApiCreate, ApiPermissionInput } from '$lib/types/api.type';
|
||||
import type { ApiCimdAccessUpdate, ApiCreate, ApiPermissionInput } from '$lib/types/api.type';
|
||||
import { axiosErrorToast } from '$lib/utils/error-util';
|
||||
import { LucideChevronLeft } from '@lucide/svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { backNavigate } from '../../users/navigate-back-util';
|
||||
import ApiForm from '../api-form.svelte';
|
||||
import ApiAccessCard from './api-access-card.svelte';
|
||||
import ApiPermissionsInput from './api-permissions-input.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
let api = $state(data.api);
|
||||
let permissions = $state<ApiPermissionInput[]>(
|
||||
data.api.permissions.map((p) => ({
|
||||
let permissions = $state<ApiPermissionInput[]>(toPermissionInputs(data.api.permissions));
|
||||
|
||||
function toPermissionInputs(apiPermissions: typeof data.api.permissions): ApiPermissionInput[] {
|
||||
const inputs = apiPermissions.map((p) => ({
|
||||
key: p.key,
|
||||
name: p.name,
|
||||
description: p.description ?? ''
|
||||
}))
|
||||
);
|
||||
}));
|
||||
// Show an empty row so the user doesn't have to add one first
|
||||
return inputs.length > 0 ? inputs : [{ key: '', name: '', description: '' }];
|
||||
}
|
||||
|
||||
function isEmptyPermission(p: ApiPermissionInput) {
|
||||
return !p.key.trim() && !p.name.trim() && !p.description.trim();
|
||||
}
|
||||
|
||||
const apisService = new ApisService();
|
||||
const backNavigation = backNavigate('/settings/admin/apis');
|
||||
|
||||
let accessCard = $state<ApiAccessCard>();
|
||||
|
||||
async function updateApi(updated: ApiCreate) {
|
||||
let success = true;
|
||||
await apisService
|
||||
@@ -42,16 +53,31 @@
|
||||
|
||||
async function updatePermissions() {
|
||||
await apisService
|
||||
.updatePermissions(api.id, permissions)
|
||||
.updatePermissions(
|
||||
api.id,
|
||||
permissions.filter((p) => !isEmptyPermission(p))
|
||||
)
|
||||
.then((res) => {
|
||||
permissions = res.permissions.map((p) => ({
|
||||
key: p.key,
|
||||
name: p.name,
|
||||
description: p.description ?? ''
|
||||
}));
|
||||
api = res;
|
||||
permissions = toPermissionInputs(res.permissions);
|
||||
toast.success(m.api_permissions_updated_successfully());
|
||||
})
|
||||
.catch(axiosErrorToast);
|
||||
|
||||
// A removed permission takes the client grants that referenced it with it
|
||||
await accessCard?.refresh();
|
||||
}
|
||||
|
||||
async function updateCimdAccess(update: ApiCimdAccessUpdate) {
|
||||
await apisService
|
||||
.updateCimdAccess(api.id, update)
|
||||
.then((res) => {
|
||||
api = res;
|
||||
toast.success(m.api_access_updated_successfully());
|
||||
})
|
||||
.catch(axiosErrorToast);
|
||||
|
||||
await accessCard?.refresh();
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -86,3 +112,5 @@
|
||||
<Button usePromiseLoading onclick={updatePermissions}>{m.save()}</Button>
|
||||
</div>
|
||||
</CollapsibleCard>
|
||||
|
||||
<ApiAccessCard bind:this={accessCard} {api} onCimdAccessSave={updateCimdAccess} />
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import * as Tabs from '$lib/components/ui/tabs';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import type { Api, ApiCimdAccessUpdate } from '$lib/types/api.type';
|
||||
import ApiCimdAccessTab from './api-cimd-access-tab.svelte';
|
||||
import ApiClientsTab from './api-clients-tab.svelte';
|
||||
|
||||
let {
|
||||
api,
|
||||
onCimdAccessSave
|
||||
}: { api: Api; onCimdAccessSave: (update: ApiCimdAccessUpdate) => Promise<void> } = $props();
|
||||
|
||||
let clientsTab = $state<ApiClientsTab>();
|
||||
let tab = $state('clients');
|
||||
|
||||
export async function refresh() {
|
||||
await clientsTab?.refresh();
|
||||
}
|
||||
</script>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>{m.access()}</Card.Title>
|
||||
<Card.Description>{m.api_access_card_description()}</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<Tabs.Root bind:value={tab} class="gap-4">
|
||||
<div class="flex flex-col items-start justify-between gap-3 sm:flex-row sm:items-center">
|
||||
<Tabs.List>
|
||||
<Tabs.Trigger value="clients">{m.oidc_clients()}</Tabs.Trigger>
|
||||
<Tabs.Trigger value="metadata-document-clients">
|
||||
{m.metadata_document_client_access()}
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
{#if tab === 'clients'}
|
||||
<Button variant="outline" onclick={() => clientsTab?.openPicker()}>
|
||||
{m.add_client()}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Tabs.Content value="clients">
|
||||
<ApiClientsTab bind:this={clientsTab} {api} />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="metadata-document-clients">
|
||||
<ApiCimdAccessTab {api} onSave={onCimdAccessSave} />
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
@@ -0,0 +1,78 @@
|
||||
<script lang="ts">
|
||||
import FormInput from '$lib/components/form/form-input.svelte';
|
||||
import SwitchWithLabel from '$lib/components/form/switch-with-label.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import Checkbox from '$lib/components/ui/checkbox/checkbox.svelte';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import type { Api, ApiCimdAccessUpdate } from '$lib/types/api.type';
|
||||
import { preventDefault } from '$lib/utils/event-util';
|
||||
import { createForm } from '$lib/utils/form-util';
|
||||
import { z } from 'zod/v4';
|
||||
|
||||
let { api, onSave }: { api: Api; onSave: (update: ApiCimdAccessUpdate) => Promise<void> } =
|
||||
$props();
|
||||
|
||||
const formSchema = z.object({ enabled: z.boolean(), permissionIds: z.array(z.string()) });
|
||||
const { inputs, ...form } = createForm(formSchema, {
|
||||
enabled: api.allowCimdClients,
|
||||
permissionIds: api.permissions.filter((p) => p.allowedForCimdClients).map((p) => p.id)
|
||||
});
|
||||
|
||||
async function save() {
|
||||
const data = form.validate();
|
||||
if (data) await onSave(data);
|
||||
}
|
||||
</script>
|
||||
|
||||
<form novalidate onsubmit={preventDefault(save)}>
|
||||
<FormInput bind:input={$inputs.enabled} class="my-5">
|
||||
<SwitchWithLabel
|
||||
id="allow-cimd-clients"
|
||||
label={m.allow_all_metadata_document_clients()}
|
||||
description={m.allow_all_metadata_document_clients_description()}
|
||||
bind:checked={$inputs.enabled.value}
|
||||
/>
|
||||
</FormInput>
|
||||
|
||||
{#if $inputs.enabled.value}
|
||||
<div class="mt-6">
|
||||
{#if api.permissions.length > 0}
|
||||
<FormInput bind:input={$inputs.permissionIds}>
|
||||
<Label class="mb-3">{m.granted_permissions()}</Label>
|
||||
<div class="flex flex-col gap-3">
|
||||
{#each api.permissions as permission (permission.id)}
|
||||
<div class="flex items-start gap-2">
|
||||
<Checkbox
|
||||
id={`cimd-permission-${permission.id}`}
|
||||
class="mt-0.5"
|
||||
checked={$inputs.permissionIds.value.includes(permission.id)}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
form.setValue(
|
||||
'permissionIds',
|
||||
checked
|
||||
? [...$inputs.permissionIds.value, permission.id]
|
||||
: $inputs.permissionIds.value.filter((id) => id !== permission.id)
|
||||
)}
|
||||
/>
|
||||
<div class="grid gap-1 leading-none">
|
||||
<Label
|
||||
for={`cimd-permission-${permission.id}`}
|
||||
class="mb-0 text-sm leading-none font-medium"
|
||||
>
|
||||
{permission.name}
|
||||
</Label>
|
||||
<p class="text-muted-foreground font-mono text-xs">{permission.key}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</FormInput>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-5 flex justify-end">
|
||||
<Button type="submit" usePromiseLoading>{m.save()}</Button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -0,0 +1,171 @@
|
||||
<script lang="ts">
|
||||
import ApiAccessCell from '$lib/components/api-access-cell.svelte';
|
||||
import ApiPermissionsModal from '$lib/components/api-permissions-modal.svelte';
|
||||
import { openConfirmDialog } from '$lib/components/confirm-dialog';
|
||||
import OidcClientAvatar from '$lib/components/oidc-client-avatar.svelte';
|
||||
import AdvancedTable from '$lib/components/table/advanced-table.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import ApisService from '$lib/services/apis-service';
|
||||
import type { AdvancedTableColumn } from '$lib/types/advanced-table.type';
|
||||
import type { Api, ApiClient, ApiClientAccess, ApiClientGrant } from '$lib/types/api.type';
|
||||
import type { ListRequestOptions, Paginated } from '$lib/types/list-request.type';
|
||||
import { encodeClientIdParam } from '$lib/utils/client-id-util';
|
||||
import { axiosErrorToast } from '$lib/utils/error-util';
|
||||
import { LucidePencil, LucideTrash } from '@lucide/svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import ClientSelectionModal from './client-selection-modal.svelte';
|
||||
|
||||
let { api }: { api: Api } = $props();
|
||||
|
||||
const apisService = new ApisService();
|
||||
|
||||
type ClientRow = ApiClientAccess & { id: string };
|
||||
|
||||
let tableRef: AdvancedTable<ClientRow>;
|
||||
let editing = $state<ApiClientAccess | null>(null);
|
||||
let modalOpen = $state(false);
|
||||
let pickerOpen = $state(false);
|
||||
|
||||
const columns: AdvancedTableColumn<ClientRow>[] = [
|
||||
{ label: m.client(), key: 'client', cell: ClientCell },
|
||||
{ label: m.user_delegated_access(), key: 'user-access', cell: UserAccessCell },
|
||||
{ label: m.client_access(), key: 'client-access', cell: ClientAccessCell },
|
||||
{ label: '', key: 'actions', cell: ActionsCell }
|
||||
];
|
||||
|
||||
async function fetchCallback(options: ListRequestOptions): Promise<Paginated<ClientRow>> {
|
||||
const res = await apisService.listClients(api.id, options);
|
||||
return { ...res, data: res.data.map((entry) => ({ ...entry, id: entry.client.id })) };
|
||||
}
|
||||
|
||||
export async function refresh() {
|
||||
await tableRef?.refresh();
|
||||
}
|
||||
|
||||
export function openPicker() {
|
||||
pickerOpen = true;
|
||||
}
|
||||
|
||||
function openEdit(entry: ApiClientAccess) {
|
||||
editing = entry;
|
||||
modalOpen = true;
|
||||
}
|
||||
|
||||
function addClient(client: ApiClient) {
|
||||
editing = {
|
||||
client,
|
||||
userDelegatedAccess: true,
|
||||
clientAccess: false,
|
||||
userDelegatedPermissionIds: [],
|
||||
clientPermissionIds: [],
|
||||
cimdGrantedAccess: false,
|
||||
cimdGrantedPermissionIds: []
|
||||
};
|
||||
|
||||
pickerOpen = false;
|
||||
modalOpen = true;
|
||||
}
|
||||
|
||||
async function save(entry: ApiClientAccess, grant: ApiClientGrant) {
|
||||
await apisService.updateClientAccessForApi(api.id, entry.client.id, {
|
||||
...grant,
|
||||
clientAccess: entry.client.isPublic ? false : grant.clientAccess,
|
||||
clientPermissionIds: entry.client.isPublic ? [] : grant.clientPermissionIds
|
||||
});
|
||||
|
||||
await tableRef?.refresh();
|
||||
toast.success(m.api_access_updated_successfully());
|
||||
}
|
||||
|
||||
function userGrantedCount(entry: ApiClientAccess) {
|
||||
return new Set([...entry.userDelegatedPermissionIds, ...entry.cimdGrantedPermissionIds]).size;
|
||||
}
|
||||
|
||||
function removeClient(entry: ApiClientAccess) {
|
||||
openConfirmDialog({
|
||||
title: m.revoke_access_for_name({ name: entry.client.name }),
|
||||
message: m.are_you_sure_you_want_to_revoke_the_api_access_of_this_client(),
|
||||
confirm: {
|
||||
label: m.revoke(),
|
||||
destructive: true,
|
||||
action: async () => {
|
||||
try {
|
||||
await apisService.removeClientAccessForApi(api.id, entry.client.id);
|
||||
await tableRef?.refresh();
|
||||
toast.success(m.api_access_updated_successfully());
|
||||
} catch (e) {
|
||||
axiosErrorToast(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet ClientCell({ item }: { item: ClientRow })}
|
||||
<div class="flex items-center gap-3">
|
||||
<OidcClientAvatar id={item.client.id} name={item.client.name} hasLogo={item.client.hasLogo} />
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<a
|
||||
class="font-medium hover:underline"
|
||||
href={`/settings/admin/oidc-clients/${encodeClientIdParam(item.client.id)}`}
|
||||
>
|
||||
{item.client.name}
|
||||
</a>
|
||||
{#if item.client.clientType === 'cimd'}
|
||||
<span class="text-muted-foreground text-xs">{m.client_type_metadata_document()}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet UserAccessCell({ item }: { item: ClientRow })}
|
||||
<ApiAccessCell
|
||||
hasAccess={item.userDelegatedAccess || item.cimdGrantedAccess}
|
||||
granted={userGrantedCount(item)}
|
||||
total={api.permissions.length}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
{#snippet ClientAccessCell({ item }: { item: ClientRow })}
|
||||
<ApiAccessCell
|
||||
hasAccess={item.clientAccess}
|
||||
granted={item.clientPermissionIds.length}
|
||||
total={api.permissions.length}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
{#snippet ActionsCell({ item }: { item: ClientRow })}
|
||||
<div class="flex justify-end gap-1">
|
||||
<Button variant="ghost" size="sm" aria-label={m.edit()} onclick={() => openEdit(item)}>
|
||||
<LucidePencil class="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-label={m.revoke()}
|
||||
disabled={!item.userDelegatedAccess && !item.clientAccess}
|
||||
onclick={() => removeClient(item)}
|
||||
>
|
||||
<LucideTrash class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<AdvancedTable id={`api-clients-${api.id}`} bind:this={tableRef} {columns} {fetchCallback} />
|
||||
|
||||
<ClientSelectionModal bind:open={pickerOpen} apiId={api.id} onSelect={addClient} />
|
||||
|
||||
{#if editing}
|
||||
<ApiPermissionsModal
|
||||
bind:open={modalOpen}
|
||||
{api}
|
||||
grant={editing}
|
||||
implicitUserAccess={editing.cimdGrantedAccess}
|
||||
implicitUserIds={editing.cimdGrantedPermissionIds}
|
||||
showClientAccess={!editing.client.isPublic}
|
||||
title={editing.client.name}
|
||||
onSave={(grant) => save(editing!, grant)}
|
||||
/>
|
||||
{/if}
|
||||
@@ -0,0 +1,59 @@
|
||||
<script lang="ts">
|
||||
import OidcClientAvatar from '$lib/components/oidc-client-avatar.svelte';
|
||||
import AdvancedTable from '$lib/components/table/advanced-table.svelte';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import ApisService from '$lib/services/apis-service';
|
||||
import type { AdvancedTableColumn } from '$lib/types/advanced-table.type';
|
||||
import type { ApiClient } from '$lib/types/api.type';
|
||||
import type { ListRequestOptions } from '$lib/types/list-request.type';
|
||||
|
||||
let {
|
||||
open = $bindable(),
|
||||
apiId,
|
||||
onSelect
|
||||
}: {
|
||||
open: boolean;
|
||||
apiId: string;
|
||||
onSelect: (client: ApiClient) => void;
|
||||
} = $props();
|
||||
|
||||
const apisService = new ApisService();
|
||||
|
||||
const columns: AdvancedTableColumn<ApiClient>[] = [
|
||||
{ label: m.logo(), key: 'logo', cell: LogoCell },
|
||||
{ label: m.name(), column: 'name', sortable: true },
|
||||
{
|
||||
label: m.client_type(),
|
||||
column: 'clientType',
|
||||
sortable: true,
|
||||
value: (item) =>
|
||||
item.clientType === 'cimd' ? m.client_type_metadata_document() : m.client_type_standard()
|
||||
}
|
||||
];
|
||||
|
||||
function fetchCallback(options: ListRequestOptions) {
|
||||
return apisService.listAssignableClients(apiId, options);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet LogoCell({ item }: { item: ApiClient })}
|
||||
<OidcClientAvatar id={item.id} name={item.name} hasLogo={item.hasLogo} />
|
||||
{/snippet}
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="max-h-[90vh] min-w-[90vw] overflow-auto lg:min-w-250">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{m.add_client()}</Dialog.Title>
|
||||
<Dialog.Description>{m.select_a_client_to_grant_access_to_this_api()}</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<AdvancedTable
|
||||
id="api-client-selection"
|
||||
onRowClick={(item) => onSelect(item)}
|
||||
{fetchCallback}
|
||||
defaultSort={{ column: 'name', direction: 'asc' }}
|
||||
{columns}
|
||||
/>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -24,7 +24,12 @@
|
||||
{ label: 'ID', column: 'id', hidden: true },
|
||||
{ label: m.name(), column: 'name', sortable: true },
|
||||
{ label: m.api_resource(), column: 'resource', sortable: true },
|
||||
{ label: m.api_permissions(), key: 'permissions', value: (item) => item.permissions.length }
|
||||
{ label: m.api_permissions(), key: 'permissions', value: (item) => item.permissions.length },
|
||||
{
|
||||
label: m.metadata_document_client_access(),
|
||||
column: 'allowCimdClients',
|
||||
value: (item) => (item.allowCimdClients ? m.enabled() : m.disabled())
|
||||
}
|
||||
];
|
||||
|
||||
const actions: CreateAdvancedTableActions<Api> = () => [
|
||||
|
||||
@@ -359,15 +359,7 @@
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="api-access" id="api-access">
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>{m.api_access()}</Card.Title>
|
||||
<Card.Description>{m.api_access_description()}</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<ApiAccessCard clientId={client.id} isPublicClient={client.isPublic} />
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
<ApiAccessCard clientId={client.id} isPublicClient={client.isPublic} />
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="scim" id="scim-provisioning">
|
||||
|
||||
@@ -1,147 +1,212 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import ApiAccessCell from '$lib/components/api-access-cell.svelte';
|
||||
import ApiPermissionsModal from '$lib/components/api-permissions-modal.svelte';
|
||||
import { openConfirmDialog } from '$lib/components/confirm-dialog';
|
||||
import CopyToClipboard from '$lib/components/copy-to-clipboard.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Spinner } from '$lib/components/ui/spinner';
|
||||
import * as Table from '$lib/components/ui/table';
|
||||
import Empty from '$lib/icons/empty.svelte';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import ApisService from '$lib/services/apis-service';
|
||||
import type { Api } from '$lib/types/api.type';
|
||||
import type { Api, ApiClientGrant, ClientApiGrant } from '$lib/types/api.type';
|
||||
import { axiosErrorToast } from '$lib/utils/error-util';
|
||||
import { LucidePencil, LucideTrash } from '@lucide/svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import ApiPermissionsModal from './api-permissions-modal.svelte';
|
||||
import ApiSelectionModal from './api-selection-modal.svelte';
|
||||
|
||||
let { clientId, isPublicClient }: { clientId: string; isPublicClient: boolean } = $props();
|
||||
|
||||
const apisService = new ApisService();
|
||||
|
||||
let apis = $state<Api[]>([]);
|
||||
let userSelected = $state<Set<string>>(new Set());
|
||||
let clientSelected = $state<Set<string>>(new Set());
|
||||
let grants = $state<ClientApiGrant[]>([]);
|
||||
let loading = $state(true);
|
||||
|
||||
let editingApi = $state<Api | null>(null);
|
||||
let editing = $state<ClientApiGrant | null>(null);
|
||||
let modalOpen = $state(false);
|
||||
let pickerOpen = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
onMount(load);
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const [list, access] = await Promise.all([
|
||||
apisService.listAll(),
|
||||
apisService.getClientAccess(clientId)
|
||||
]);
|
||||
apis = list;
|
||||
userSelected = new Set(access.userDelegatedPermissionIds);
|
||||
clientSelected = new Set(access.clientPermissionIds);
|
||||
grants = await apisService.listClientApis(clientId);
|
||||
} catch (e) {
|
||||
axiosErrorToast(e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
|
||||
function grantedCount(api: Api, selected: Set<string>) {
|
||||
return api.permissions.filter((p) => selected.has(p.id)).length;
|
||||
}
|
||||
|
||||
function openEdit(api: Api) {
|
||||
editingApi = api;
|
||||
function openEdit(grant: ClientApiGrant) {
|
||||
editing = grant;
|
||||
modalOpen = true;
|
||||
}
|
||||
|
||||
function allowedIdsFor(api: Api, selected: Set<string>) {
|
||||
return api.permissions.filter((p) => selected.has(p.id)).map((p) => p.id);
|
||||
function addApi(api: Api) {
|
||||
editing = {
|
||||
api,
|
||||
userDelegatedAccess: true,
|
||||
clientAccess: false,
|
||||
userDelegatedPermissionIds: [],
|
||||
clientPermissionIds: [],
|
||||
cimdGrantedAccess: false,
|
||||
cimdGrantedPermissionIds: []
|
||||
};
|
||||
|
||||
pickerOpen = false;
|
||||
modalOpen = true;
|
||||
}
|
||||
|
||||
async function saveApi(api: Api, userIds: string[], clientIds: string[]) {
|
||||
// Grants of other APIs stay untouched, and for public clients the (never editable) client grants are sent back unchanged
|
||||
const otherUser = [...userSelected].filter((id) => !api.permissions.some((p) => p.id === id));
|
||||
const otherClient = [...clientSelected].filter(
|
||||
(id) => !api.permissions.some((p) => p.id === id)
|
||||
);
|
||||
const res = await apisService.updateClientAccess(clientId, {
|
||||
userDelegatedPermissionIds: [...otherUser, ...userIds],
|
||||
clientPermissionIds: isPublicClient ? [...clientSelected] : [...otherClient, ...clientIds]
|
||||
async function save(entry: ClientApiGrant, grant: ApiClientGrant) {
|
||||
await apisService.updateClientAccessForApi(entry.api.id, clientId, {
|
||||
...grant,
|
||||
clientAccess: isPublicClient ? false : grant.clientAccess,
|
||||
clientPermissionIds: isPublicClient ? [] : grant.clientPermissionIds
|
||||
});
|
||||
userSelected = new Set(res.userDelegatedPermissionIds);
|
||||
clientSelected = new Set(res.clientPermissionIds);
|
||||
|
||||
await load();
|
||||
toast.success(m.api_access_updated_successfully());
|
||||
}
|
||||
|
||||
function removeApi(entry: ClientApiGrant) {
|
||||
openConfirmDialog({
|
||||
title: m.revoke_access_to_name({ name: entry.api.name }),
|
||||
message: m.are_you_sure_you_want_to_revoke_the_access_of_this_client_to_the_api(),
|
||||
confirm: {
|
||||
label: m.revoke(),
|
||||
destructive: true,
|
||||
action: async () => {
|
||||
try {
|
||||
await apisService.removeClientAccessForApi(entry.api.id, clientId);
|
||||
await load();
|
||||
toast.success(m.api_access_updated_successfully());
|
||||
} catch (e) {
|
||||
axiosErrorToast(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function userGrantedCount(entry: ClientApiGrant) {
|
||||
const granted = new Set([
|
||||
...entry.userDelegatedPermissionIds,
|
||||
...entry.cimdGrantedPermissionIds
|
||||
]);
|
||||
return granted.size;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
<div class="flex justify-center py-6">
|
||||
<Spinner class="size-6" />
|
||||
</div>
|
||||
{:else if apis.length === 0}
|
||||
<div class="flex flex-col items-center justify-center gap-2 py-6">
|
||||
<p class="text-muted-foreground text-sm">{m.no_apis_defined_yet()}</p>
|
||||
<Button variant="outline" size="sm" onclick={() => goto('/settings/admin/apis')}>
|
||||
{m.create_api()}
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head>{m.api_name()}</Table.Head>
|
||||
<Table.Head>{m.user_delegated_access()}</Table.Head>
|
||||
{#if !isPublicClient}
|
||||
<Table.Head>{m.client_access()}</Table.Head>
|
||||
{/if}
|
||||
<Table.Head class="w-20"></Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each apis as api (api.id)}
|
||||
<Table.Row>
|
||||
<Table.Cell>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="font-medium">{api.name}</span>
|
||||
<div>
|
||||
<CopyToClipboard value={api.resource}>
|
||||
<span class="text-muted-foreground font-mono text-xs break-all"
|
||||
>{api.resource}</span
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<div class="flex flex-col items-start justify-between gap-3 sm:flex-row sm:items-center">
|
||||
<div>
|
||||
<Card.Title>{m.api_access()}</Card.Title>
|
||||
<Card.Description>{m.api_access_description()}</Card.Description>
|
||||
</div>
|
||||
<Button onclick={() => (pickerOpen = true)}>{m.add_api()}</Button>
|
||||
</div>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
{#if loading}
|
||||
<div class="flex justify-center py-6">
|
||||
<Spinner class="size-6" />
|
||||
</div>
|
||||
{:else if grants.length === 0}
|
||||
<div class="my-5 flex flex-col items-center">
|
||||
<Empty class="text-muted-foreground h-20" />
|
||||
<p class="text-muted-foreground mt-3 text-sm">{m.this_client_has_no_api_access_yet()}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head>{m.api_name()}</Table.Head>
|
||||
<Table.Head>{m.user_delegated_access()}</Table.Head>
|
||||
{#if !isPublicClient}
|
||||
<Table.Head>{m.client_access()}</Table.Head>
|
||||
{/if}
|
||||
<Table.Head class="w-20"></Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each grants as entry (entry.api.id)}
|
||||
<Table.Row>
|
||||
<Table.Cell>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="font-medium">{entry.api.name}</span>
|
||||
<div>
|
||||
<CopyToClipboard value={entry.api.resource}>
|
||||
<span class="text-muted-foreground font-mono text-xs break-all"
|
||||
>{entry.api.resource}</span
|
||||
>
|
||||
</CopyToClipboard>
|
||||
</div>
|
||||
{#if entry.cimdGrantedAccess}
|
||||
<span class="text-muted-foreground text-xs"
|
||||
>{m.granted_to_all_cimd_clients()}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<ApiAccessCell
|
||||
hasAccess={entry.userDelegatedAccess || entry.cimdGrantedAccess}
|
||||
granted={userGrantedCount(entry)}
|
||||
total={entry.api.permissions.length}
|
||||
/>
|
||||
</Table.Cell>
|
||||
{#if !isPublicClient}
|
||||
<Table.Cell>
|
||||
<ApiAccessCell
|
||||
hasAccess={entry.clientAccess}
|
||||
granted={entry.clientPermissionIds.length}
|
||||
total={entry.api.permissions.length}
|
||||
/>
|
||||
</Table.Cell>
|
||||
{/if}
|
||||
<Table.Cell class="text-right">
|
||||
<div class="flex justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-label={m.edit()}
|
||||
onclick={() => openEdit(entry)}
|
||||
>
|
||||
</CopyToClipboard>
|
||||
</div>
|
||||
</div>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-muted-foreground text-sm">
|
||||
{m.permissions_granted_count({
|
||||
granted: String(grantedCount(api, userSelected)),
|
||||
total: String(api.permissions.length)
|
||||
})}
|
||||
</Table.Cell>
|
||||
{#if !isPublicClient}
|
||||
<Table.Cell class="text-muted-foreground text-sm">
|
||||
{m.permissions_granted_count({
|
||||
granted: String(grantedCount(api, clientSelected)),
|
||||
total: String(api.permissions.length)
|
||||
})}
|
||||
</Table.Cell>
|
||||
{/if}
|
||||
<Table.Cell class="text-right">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={api.permissions.length === 0}
|
||||
onclick={() => openEdit(api)}>{m.edit()}</Button
|
||||
>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
{/if}
|
||||
<LucidePencil class="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-label={m.revoke()}
|
||||
disabled={!entry.userDelegatedAccess && !entry.clientAccess}
|
||||
onclick={() => removeApi(entry)}
|
||||
>
|
||||
<LucideTrash class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
{#if editingApi}
|
||||
<ApiSelectionModal bind:open={pickerOpen} {clientId} onSelect={addApi} />
|
||||
|
||||
{#if editing}
|
||||
<ApiPermissionsModal
|
||||
bind:open={modalOpen}
|
||||
api={editingApi}
|
||||
userAllowedIds={allowedIdsFor(editingApi, userSelected)}
|
||||
clientAllowedIds={allowedIdsFor(editingApi, clientSelected)}
|
||||
api={editing.api}
|
||||
grant={editing}
|
||||
implicitUserAccess={editing.cimdGrantedAccess}
|
||||
implicitUserIds={editing.cimdGrantedPermissionIds}
|
||||
showClientAccess={!isPublicClient}
|
||||
onSave={(userIds, clientIds) => saveApi(editingApi!, userIds, clientIds)}
|
||||
onSave={(grant) => save(editing!, grant)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
<script lang="ts">
|
||||
import AdvancedTable from '$lib/components/table/advanced-table.svelte';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import ApisService from '$lib/services/apis-service';
|
||||
import type { AdvancedTableColumn } from '$lib/types/advanced-table.type';
|
||||
import type { Api } from '$lib/types/api.type';
|
||||
import type { ListRequestOptions } from '$lib/types/list-request.type';
|
||||
|
||||
let {
|
||||
open = $bindable(),
|
||||
clientId,
|
||||
onSelect
|
||||
}: {
|
||||
open: boolean;
|
||||
clientId: string;
|
||||
onSelect: (api: Api) => void;
|
||||
} = $props();
|
||||
|
||||
const apisService = new ApisService();
|
||||
|
||||
const columns: AdvancedTableColumn<Api>[] = [
|
||||
{ label: m.name(), column: 'name', sortable: true },
|
||||
{ label: m.api_resource(), column: 'resource', sortable: true },
|
||||
{ label: m.api_permissions(), key: 'permissions', value: (item) => item.permissions.length }
|
||||
];
|
||||
|
||||
function fetchCallback(options: ListRequestOptions) {
|
||||
return apisService.listAssignableApis(clientId, options);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="max-h-[90vh] min-w-[90vw] overflow-auto lg:min-w-250">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{m.add_api()}</Dialog.Title>
|
||||
<Dialog.Description>{m.select_an_api_this_client_should_access()}</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<AdvancedTable
|
||||
id="client-api-selection"
|
||||
{fetchCallback}
|
||||
onRowClick={(item) => onSelect(item)}
|
||||
defaultSort={{ column: 'name', direction: 'asc' }}
|
||||
{columns}
|
||||
/>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"provider": "sqlite",
|
||||
"version": 20260807120000,
|
||||
"version": 20260814120000,
|
||||
"tableOrder": [
|
||||
"users",
|
||||
"user_groups",
|
||||
@@ -9,6 +9,7 @@
|
||||
"signup_tokens",
|
||||
"apis",
|
||||
"api_permissions",
|
||||
"oidc_clients_allowed_apis",
|
||||
"oidc_clients_allowed_api_permissions"
|
||||
],
|
||||
"tables": {
|
||||
@@ -18,7 +19,8 @@
|
||||
"created_at": "2025-11-25T12:39:02Z",
|
||||
"updated_at": null,
|
||||
"name": "Orders API",
|
||||
"audience": "https://api.orders.test"
|
||||
"audience": "https://api.orders.test",
|
||||
"allow_cimd_clients": false
|
||||
}
|
||||
],
|
||||
"api_permissions": [
|
||||
@@ -28,7 +30,8 @@
|
||||
"api_id": "f6a8b3c1-2d4e-4a6b-8c9d-0e1f2a3b4c5d",
|
||||
"key": "read:orders",
|
||||
"name": "Read orders",
|
||||
"description": "Read order data"
|
||||
"description": "Read order data",
|
||||
"allowed_for_cimd_clients": false
|
||||
},
|
||||
{
|
||||
"id": "2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e",
|
||||
@@ -36,7 +39,20 @@
|
||||
"api_id": "f6a8b3c1-2d4e-4a6b-8c9d-0e1f2a3b4c5d",
|
||||
"key": "write:orders",
|
||||
"name": "Write orders",
|
||||
"description": "Create and modify orders"
|
||||
"description": "Create and modify orders",
|
||||
"allowed_for_cimd_clients": false
|
||||
}
|
||||
],
|
||||
"oidc_clients_allowed_apis": [
|
||||
{
|
||||
"oidc_client_id": "606c7782-f2b1-49e5-8ea9-26eb1b06d018",
|
||||
"api_id": "f6a8b3c1-2d4e-4a6b-8c9d-0e1f2a3b4c5d",
|
||||
"subject_type": "user"
|
||||
},
|
||||
{
|
||||
"oidc_client_id": "606c7782-f2b1-49e5-8ea9-26eb1b06d018",
|
||||
"api_id": "f6a8b3c1-2d4e-4a6b-8c9d-0e1f2a3b4c5d",
|
||||
"subject_type": "client"
|
||||
}
|
||||
],
|
||||
"oidc_clients_allowed_api_permissions": [
|
||||
|
||||
@@ -110,12 +110,13 @@ test('Grant a client user-delegated and client access to API permissions', async
|
||||
// Nextcloud has no API access granted by default
|
||||
await page.goto(`/settings/admin/oidc-clients/${oidcClients.nextcloud.id}`);
|
||||
|
||||
// Open the API access tab, then edit the Orders API row
|
||||
// Open the API access tab, where no API is listed yet, and add the Orders API
|
||||
await page.getByRole('tab', { name: 'API access' }).click();
|
||||
await page
|
||||
.getByRole('row', { name: apis.orders.name })
|
||||
.getByRole('button', { name: 'Edit' })
|
||||
.click();
|
||||
await expect(
|
||||
page.getByText('This client has not been granted access to any API yet.')
|
||||
).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Add API' }).click();
|
||||
await page.getByRole('row', { name: apis.orders.name }).click();
|
||||
|
||||
// Grant read:orders and write:orders on behalf of users, but only write:orders for the client itself
|
||||
const dialog = page.getByRole('dialog');
|
||||
@@ -137,10 +138,140 @@ test('Grant a client user-delegated and client access to API permissions', async
|
||||
await dialog.getByRole('button', { name: 'Save' }).click();
|
||||
|
||||
await expect(page.locator('[data-type="success"]')).toHaveText('API access updated successfully');
|
||||
// The dialogs carry rows with the same names, so the assertions below wait until they are gone
|
||||
await expect(page.getByRole('dialog')).toHaveCount(0);
|
||||
// Both subject types keep their own count: 2 / 2 user-delegated, 1 / 2 client access
|
||||
const row = page.getByRole('row', { name: apis.orders.name });
|
||||
await expect(row).toContainText('2 / 2');
|
||||
await expect(row).toContainText('1 / 2');
|
||||
|
||||
// The API is not offered a second time, because the selection is filtered server-side
|
||||
await page.getByRole('button', { name: 'Add API' }).click();
|
||||
await expect(page.getByRole('dialog').getByText('No items found')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Grant a client access from the API details page', async ({ page }) => {
|
||||
await page.goto(`/settings/admin/apis/${apis.orders.id}`);
|
||||
|
||||
// Nextcloud has no API access granted by default, so it can be picked from the client selection
|
||||
await page.getByRole('button', { name: 'Add client' }).click();
|
||||
await page.getByRole('row', { name: oidcClients.nextcloud.name }).click();
|
||||
|
||||
await page
|
||||
.getByRole('checkbox', {
|
||||
name: `User-delegated access: ${apis.orders.permissions.readOrders.name}`
|
||||
})
|
||||
.click();
|
||||
await page
|
||||
.getByRole('checkbox', {
|
||||
name: `Client access (M2M): ${apis.orders.permissions.writeOrders.name}`
|
||||
})
|
||||
.click();
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Save' }).click();
|
||||
|
||||
await expect(page.locator('[data-type="success"]')).toHaveText('API access updated successfully');
|
||||
// The client selection dialog carries a row with the same name, so this waits until it is gone
|
||||
await expect(page.getByRole('dialog')).toHaveCount(0);
|
||||
const row = page.getByRole('row', { name: oidcClients.nextcloud.name });
|
||||
await expect(row).toContainText('1 / 2');
|
||||
|
||||
// Clients that already have access are filtered out of the selection, the others stay
|
||||
await page.getByRole('button', { name: 'Add client' }).click();
|
||||
const picker = page.getByRole('dialog');
|
||||
await expect(picker.getByRole('row', { name: oidcClients.tailscale.name })).toBeVisible();
|
||||
await expect(picker.getByRole('row', { name: oidcClients.nextcloud.name })).toHaveCount(0);
|
||||
await expect(picker.getByRole('row', { name: oidcClients.immich.name })).toHaveCount(0);
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// The same grant shows up on the client's side of the relation
|
||||
await page.goto(`/settings/admin/oidc-clients/${oidcClients.nextcloud.id}`);
|
||||
await page.getByRole('tab', { name: 'API access' }).click();
|
||||
await expect(page.getByRole('row', { name: apis.orders.name })).toContainText('1 / 2');
|
||||
});
|
||||
|
||||
test('Grant a client access to an API without any permission', async ({ page, baseURL }) => {
|
||||
const client = oidcClients.nextcloud;
|
||||
const api = apis.orders;
|
||||
|
||||
// The client is added with user-delegated access and no permission at all
|
||||
await page.goto(`/settings/admin/apis/${api.id}`);
|
||||
await page.getByRole('button', { name: 'Add client' }).click();
|
||||
await page.getByRole('row', { name: client.name }).click();
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Save' }).click();
|
||||
|
||||
await expect(page.locator('[data-type="success"]')).toHaveText('API access updated successfully');
|
||||
// The client selection dialog carries a row with the same name, so this waits until it is gone
|
||||
await expect(page.getByRole('dialog')).toHaveCount(0);
|
||||
await expect(page.getByRole('row', { name: client.name })).toContainText('0 / 2');
|
||||
|
||||
// A resource request without any scope now succeeds, which is what MCP clients send
|
||||
const params = new URLSearchParams({
|
||||
client_id: client.id,
|
||||
response_type: 'code',
|
||||
resource: api.resource,
|
||||
redirect_uri: client.callbackUrl,
|
||||
state: 'nXx-6Qr-owc1SHBa'
|
||||
});
|
||||
|
||||
const callbackUrl = await oidcUtil.interceptCallbackRedirect(
|
||||
page,
|
||||
new URL(client.callbackUrl).pathname,
|
||||
async () => {
|
||||
await page.goto(`/authorize?${params.toString()}`);
|
||||
await page.getByRole('button', { name: 'Sign in' }).click();
|
||||
}
|
||||
);
|
||||
const code = callbackUrl.searchParams.get('code');
|
||||
expect(code).toBeTruthy();
|
||||
|
||||
const res = await oidcUtil.exchangeCode(page, {
|
||||
grant_type: 'authorization_code',
|
||||
redirect_uri: client.callbackUrl,
|
||||
code: code!,
|
||||
client_id: client.id,
|
||||
client_secret: client.secret
|
||||
});
|
||||
expect(res.access_token).toBeTruthy();
|
||||
|
||||
// The token is audienced to the API and carries no scope
|
||||
const claims = jose.decodeJwt(res.access_token!);
|
||||
expect(tokenAudiences(claims)).toContain(api.resource);
|
||||
expect(tokenAudiences(claims)).not.toContain(baseURL);
|
||||
expect(tokenScopes(claims)).toEqual([]);
|
||||
});
|
||||
|
||||
test('Revoke a client from the API details page', async ({ page }) => {
|
||||
// Immich is seeded with grants on the Orders API
|
||||
await page.goto(`/settings/admin/apis/${apis.orders.id}`);
|
||||
|
||||
const row = page.getByRole('row', { name: oidcClients.immich.name });
|
||||
await expect(row).toBeVisible();
|
||||
await row.getByRole('button', { name: 'Revoke' }).click();
|
||||
await page.getByRole('button', { name: 'Revoke' }).last().click();
|
||||
|
||||
await expect(page.locator('[data-type="success"]')).toHaveText('API access updated successfully');
|
||||
await expect(page.getByRole('row', { name: oidcClients.immich.name })).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('Allow all metadata document clients for an API', async ({ page }) => {
|
||||
await page.goto(`/settings/admin/apis/${apis.orders.id}`);
|
||||
|
||||
await page.getByRole('tab', { name: 'Metadata document clients' }).click();
|
||||
await page.getByLabel('Allow all metadata document clients').click();
|
||||
await page.getByLabel(apis.orders.permissions.readOrders.name, { exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Save' }).nth(2).click();
|
||||
|
||||
await expect(page.locator('[data-type="success"]')).toHaveText('API access updated successfully');
|
||||
|
||||
await page.reload();
|
||||
await page.getByRole('tab', { name: 'Metadata document clients' }).click();
|
||||
await expect(page.getByLabel('Allow all metadata document clients')).toBeChecked();
|
||||
await expect(
|
||||
page.getByLabel(apis.orders.permissions.readOrders.name, { exact: true })
|
||||
).toBeChecked();
|
||||
await expect(
|
||||
page.getByLabel(apis.orders.permissions.writeOrders.name, { exact: true })
|
||||
).not.toBeChecked();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user