feat: allow admins to auto grant APIs to CIMD clients (#1692)

This commit is contained in:
Elias Schneider
2026-08-18 20:02:14 +02:00
committed by GitHub
parent 22354581df
commit 915a6bcf08
38 changed files with 2605 additions and 393 deletions
+52 -13
View File
@@ -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"`
}
+200 -51
View File
@@ -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
}
+19 -7
View File
@@ -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
+12 -6
View File
@@ -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))
}
+709 -107
View File
@@ -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 {
+375 -21
View File
@@ -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 {