mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-02-04 15:04:43 +00:00
feat: add support for SCIM provisioning (#1182)
This commit is contained in:
22
.github/workflows/e2e-tests.yml
vendored
22
.github/workflows/e2e-tests.yml
vendored
@@ -117,6 +117,21 @@ jobs:
|
|||||||
if: steps.lldap-cache.outputs.cache-hit == 'true'
|
if: steps.lldap-cache.outputs.cache-hit == 'true'
|
||||||
run: docker load < /tmp/lldap-image.tar
|
run: docker load < /tmp/lldap-image.tar
|
||||||
|
|
||||||
|
- name: Cache SCIM Test Server Docker image
|
||||||
|
uses: actions/cache@v4
|
||||||
|
id: scim-cache
|
||||||
|
with:
|
||||||
|
path: /tmp/scim-test-server-image.tar
|
||||||
|
key: scim-test-server-${{ runner.os }}
|
||||||
|
- name: Pull and save SCIM Test Server image
|
||||||
|
if: steps.scim-cache.outputs.cache-hit != 'true'
|
||||||
|
run: |
|
||||||
|
docker pull ghcr.io/pocket-id/scim-test-server
|
||||||
|
docker save ghcr.io/pocket-id/scim-test-server > /tmp/scim-test-server-image.tar
|
||||||
|
- name: Load SCIM Test Server image
|
||||||
|
if: steps.scim-cache.outputs.cache-hit == 'true'
|
||||||
|
run: docker load < /tmp/scim-test-server-image.tar
|
||||||
|
|
||||||
- name: Cache Localstack S3 Docker image
|
- name: Cache Localstack S3 Docker image
|
||||||
if: matrix.storage == 's3'
|
if: matrix.storage == 's3'
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
@@ -171,7 +186,12 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
DOCKER_COMPOSE_FILE=docker-compose.yml
|
DOCKER_COMPOSE_FILE=docker-compose.yml
|
||||||
|
|
||||||
echo "FILE_BACKEND=${{ matrix.storage }}" > .env
|
cat > .env <<EOF
|
||||||
|
FILE_BACKEND=${{ matrix.storage }}
|
||||||
|
SCIM_SERVICE_PROVIDER_URL=http://localhost:18123/v2
|
||||||
|
SCIM_SERVICE_PROVIDER_URL_INTERNAL=http://scim-test-server:8080/v2
|
||||||
|
EOF
|
||||||
|
|
||||||
if [ "${{ matrix.db }}" = "postgres" ]; then
|
if [ "${{ matrix.db }}" = "postgres" ]; then
|
||||||
DOCKER_COMPOSE_FILE=docker-compose-postgres.yml
|
DOCKER_COMPOSE_FILE=docker-compose-postgres.yml
|
||||||
elif [ "${{ matrix.storage }}" = "s3" ]; then
|
elif [ "${{ matrix.storage }}" = "s3" ]; then
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ func initRouter(db *gorm.DB, svc *services) (utils.Service, error) {
|
|||||||
controller.NewUserGroupController(apiGroup, authMiddleware, svc.userGroupService)
|
controller.NewUserGroupController(apiGroup, authMiddleware, svc.userGroupService)
|
||||||
controller.NewCustomClaimController(apiGroup, authMiddleware, svc.customClaimService)
|
controller.NewCustomClaimController(apiGroup, authMiddleware, svc.customClaimService)
|
||||||
controller.NewVersionController(apiGroup, svc.versionService)
|
controller.NewVersionController(apiGroup, svc.versionService)
|
||||||
|
controller.NewScimController(apiGroup, authMiddleware, svc.scimService)
|
||||||
|
|
||||||
// Add test controller in non-production environments
|
// Add test controller in non-production environments
|
||||||
if !common.EnvConfig.AppEnv.IsProduction() {
|
if !common.EnvConfig.AppEnv.IsProduction() {
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ type services struct {
|
|||||||
auditLogService *service.AuditLogService
|
auditLogService *service.AuditLogService
|
||||||
jwtService *service.JwtService
|
jwtService *service.JwtService
|
||||||
webauthnService *service.WebAuthnService
|
webauthnService *service.WebAuthnService
|
||||||
|
scimService *service.ScimService
|
||||||
|
scimSchedulerService *service.ScimSchedulerService
|
||||||
userService *service.UserService
|
userService *service.UserService
|
||||||
customClaimService *service.CustomClaimService
|
customClaimService *service.CustomClaimService
|
||||||
oidcService *service.OidcService
|
oidcService *service.OidcService
|
||||||
@@ -70,6 +72,11 @@ func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client, ima
|
|||||||
svc.userService = service.NewUserService(db, svc.jwtService, svc.auditLogService, svc.emailService, svc.appConfigService, svc.customClaimService, svc.appImagesService, fileStorage)
|
svc.userService = service.NewUserService(db, svc.jwtService, svc.auditLogService, svc.emailService, svc.appConfigService, svc.customClaimService, svc.appImagesService, fileStorage)
|
||||||
svc.ldapService = service.NewLdapService(db, httpClient, svc.appConfigService, svc.userService, svc.userGroupService, fileStorage)
|
svc.ldapService = service.NewLdapService(db, httpClient, svc.appConfigService, svc.userService, svc.userGroupService, fileStorage)
|
||||||
svc.apiKeyService = service.NewApiKeyService(db, svc.emailService)
|
svc.apiKeyService = service.NewApiKeyService(db, svc.emailService)
|
||||||
|
svc.scimService = service.NewScimService(db, httpClient)
|
||||||
|
svc.scimSchedulerService, err = service.NewScimSchedulerService(ctx, svc.scimService)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create SCIM scheduler service: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
svc.versionService = service.NewVersionService(httpClient)
|
svc.versionService = service.NewVersionService(httpClient)
|
||||||
|
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ func NewOidcController(group *gin.RouterGroup, authMiddleware *middleware.AuthMi
|
|||||||
|
|
||||||
group.GET("/oidc/users/me/clients", authMiddleware.WithAdminNotRequired().Add(), oc.listOwnAccessibleClientsHandler)
|
group.GET("/oidc/users/me/clients", authMiddleware.WithAdminNotRequired().Add(), oc.listOwnAccessibleClientsHandler)
|
||||||
|
|
||||||
|
group.GET("/oidc/clients/:id/scim-service-provider", authMiddleware.Add(), oc.getClientScimServiceProviderHandler)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type OidcController struct {
|
type OidcController struct {
|
||||||
@@ -845,3 +847,29 @@ func (oc *OidcController) getClientPreviewHandler(c *gin.Context) {
|
|||||||
|
|
||||||
c.JSON(http.StatusOK, preview)
|
c.JSON(http.StatusOK, preview)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getClientScimServiceProviderHandler godoc
|
||||||
|
// @Summary Get SCIM service provider
|
||||||
|
// @Description Get the SCIM service provider configuration for an OIDC client
|
||||||
|
// @Tags OIDC
|
||||||
|
// @Produce json
|
||||||
|
// @Param id path string true "Client ID"
|
||||||
|
// @Success 200 {object} dto.ScimServiceProviderDTO "SCIM service provider configuration"
|
||||||
|
// @Router /api/oidc/clients/{id}/scim-service-provider [get]
|
||||||
|
func (oc *OidcController) getClientScimServiceProviderHandler(c *gin.Context) {
|
||||||
|
clientID := c.Param("id")
|
||||||
|
|
||||||
|
provider, err := oc.oidcService.GetClientScimServiceProvider(c.Request.Context(), clientID)
|
||||||
|
if err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var providerDto dto.ScimServiceProviderDTO
|
||||||
|
if err := dto.MapStruct(provider, &providerDto); err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, providerDto)
|
||||||
|
}
|
||||||
|
|||||||
122
backend/internal/controller/scim_controller.go
Normal file
122
backend/internal/controller/scim_controller.go
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/middleware"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewScimController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, scimService *service.ScimService) {
|
||||||
|
ugc := ScimController{
|
||||||
|
scimService: scimService,
|
||||||
|
}
|
||||||
|
|
||||||
|
group.POST("/scim/service-provider", authMiddleware.Add(), ugc.createServiceProviderHandler)
|
||||||
|
group.POST("/scim/service-provider/:id/sync", authMiddleware.Add(), ugc.syncServiceProviderHandler)
|
||||||
|
group.PUT("/scim/service-provider/:id", authMiddleware.Add(), ugc.updateServiceProviderHandler)
|
||||||
|
group.DELETE("/scim/service-provider/:id", authMiddleware.Add(), ugc.deleteServiceProviderHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScimController struct {
|
||||||
|
scimService *service.ScimService
|
||||||
|
}
|
||||||
|
|
||||||
|
// syncServiceProviderHandler godoc
|
||||||
|
// @Summary Sync SCIM service provider
|
||||||
|
// @Description Trigger synchronization for a SCIM service provider
|
||||||
|
// @Tags SCIM
|
||||||
|
// @Param id path string true "Service Provider ID"
|
||||||
|
// @Success 200 "OK"
|
||||||
|
// @Router /api/scim/service-provider/{id}/sync [post]
|
||||||
|
func (c *ScimController) syncServiceProviderHandler(ctx *gin.Context) {
|
||||||
|
err := c.scimService.SyncServiceProvider(ctx.Request.Context(), ctx.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
_ = ctx.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Status(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// createServiceProviderHandler godoc
|
||||||
|
// @Summary Create SCIM service provider
|
||||||
|
// @Description Create a new SCIM service provider
|
||||||
|
// @Tags SCIM
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param serviceProvider body dto.ScimServiceProviderCreateDTO true "SCIM service provider information"
|
||||||
|
// @Success 201 {object} dto.ScimServiceProviderDTO "Created SCIM service provider"
|
||||||
|
// @Router /api/scim/service-provider [post]
|
||||||
|
func (c *ScimController) createServiceProviderHandler(ctx *gin.Context) {
|
||||||
|
var input dto.ScimServiceProviderCreateDTO
|
||||||
|
if err := ctx.ShouldBindJSON(&input); err != nil {
|
||||||
|
_ = ctx.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
provider, err := c.scimService.CreateServiceProvider(ctx.Request.Context(), &input)
|
||||||
|
if err != nil {
|
||||||
|
_ = ctx.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var providerDTO dto.ScimServiceProviderDTO
|
||||||
|
if err := dto.MapStruct(provider, &providerDTO); err != nil {
|
||||||
|
_ = ctx.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSON(http.StatusCreated, providerDTO)
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateServiceProviderHandler godoc
|
||||||
|
// @Summary Update SCIM service provider
|
||||||
|
// @Description Update an existing SCIM service provider
|
||||||
|
// @Tags SCIM
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param id path string true "Service Provider ID"
|
||||||
|
// @Param serviceProvider body dto.ScimServiceProviderCreateDTO true "SCIM service provider information"
|
||||||
|
// @Success 200 {object} dto.ScimServiceProviderDTO "Updated SCIM service provider"
|
||||||
|
// @Router /api/scim/service-provider/{id} [put]
|
||||||
|
func (c *ScimController) updateServiceProviderHandler(ctx *gin.Context) {
|
||||||
|
var input dto.ScimServiceProviderCreateDTO
|
||||||
|
if err := ctx.ShouldBindJSON(&input); err != nil {
|
||||||
|
_ = ctx.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
provider, err := c.scimService.UpdateServiceProvider(ctx.Request.Context(), ctx.Param("id"), &input)
|
||||||
|
if err != nil {
|
||||||
|
_ = ctx.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var providerDTO dto.ScimServiceProviderDTO
|
||||||
|
if err := dto.MapStruct(provider, &providerDTO); err != nil {
|
||||||
|
_ = ctx.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSON(http.StatusOK, providerDTO)
|
||||||
|
}
|
||||||
|
|
||||||
|
// deleteServiceProviderHandler godoc
|
||||||
|
// @Summary Delete SCIM service provider
|
||||||
|
// @Description Delete a SCIM service provider by ID
|
||||||
|
// @Tags SCIM
|
||||||
|
// @Param id path string true "Service Provider ID"
|
||||||
|
// @Success 204 "No Content"
|
||||||
|
// @Router /api/scim/service-provider/{id} [delete]
|
||||||
|
func (c *ScimController) deleteServiceProviderHandler(ctx *gin.Context) {
|
||||||
|
err := c.scimService.DeleteServiceProvider(ctx.Request.Context(), ctx.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
_ = ctx.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
96
backend/internal/dto/scim_dto.go
Normal file
96
backend/internal/dto/scim_dto.go
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ScimServiceProviderDTO struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Endpoint string `json:"endpoint"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
LastSyncedAt *datatype.DateTime `json:"lastSyncedAt"`
|
||||||
|
OidcClient OidcClientMetaDataDto `json:"oidcClient"`
|
||||||
|
CreatedAt datatype.DateTime `json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScimServiceProviderCreateDTO struct {
|
||||||
|
Endpoint string `json:"endpoint" binding:"required,url"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
OidcClientID string `json:"oidcClientId" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScimUser struct {
|
||||||
|
ScimResourceData
|
||||||
|
UserName string `json:"userName"`
|
||||||
|
Name *ScimName `json:"name,omitempty"`
|
||||||
|
Display string `json:"displayName,omitempty"`
|
||||||
|
Active bool `json:"active"`
|
||||||
|
Emails []ScimEmail `json:"emails,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScimName struct {
|
||||||
|
GivenName string `json:"givenName,omitempty"`
|
||||||
|
FamilyName string `json:"familyName,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScimEmail struct {
|
||||||
|
Value string `json:"value"`
|
||||||
|
Primary bool `json:"primary,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScimGroup struct {
|
||||||
|
ScimResourceData
|
||||||
|
Display string `json:"displayName"`
|
||||||
|
Members []ScimGroupMember `json:"members,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScimGroupMember struct {
|
||||||
|
Value string `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScimListResponse[T any] struct {
|
||||||
|
Resources []T `json:"Resources"`
|
||||||
|
TotalResults int `json:"totalResults"`
|
||||||
|
StartIndex int `json:"startIndex"`
|
||||||
|
ItemsPerPage int `json:"itemsPerPage"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScimResourceData struct {
|
||||||
|
ID string `json:"id,omitempty"`
|
||||||
|
ExternalID string `json:"externalId,omitempty"`
|
||||||
|
Schemas []string `json:"schemas"`
|
||||||
|
Meta ScimResourceMeta `json:"meta,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScimResourceMeta struct {
|
||||||
|
Location string `json:"location,omitempty"`
|
||||||
|
ResourceType string `json:"resourceType,omitempty"`
|
||||||
|
Created time.Time `json:"created,omitempty"`
|
||||||
|
LastModified time.Time `json:"lastModified,omitempty"`
|
||||||
|
Version string `json:"version,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r ScimResourceData) GetID() string {
|
||||||
|
return r.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r ScimResourceData) GetExternalID() string {
|
||||||
|
return r.ExternalID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r ScimResourceData) GetSchemas() []string {
|
||||||
|
return r.Schemas
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r ScimResourceData) GetMeta() ScimResourceMeta {
|
||||||
|
return r.Meta
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScimResource interface {
|
||||||
|
GetID() string
|
||||||
|
GetExternalID() string
|
||||||
|
GetSchemas() []string
|
||||||
|
GetMeta() ScimResourceMeta
|
||||||
|
}
|
||||||
14
backend/internal/model/scim.go
Normal file
14
backend/internal/model/scim.go
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||||
|
|
||||||
|
type ScimServiceProvider struct {
|
||||||
|
Base
|
||||||
|
|
||||||
|
Endpoint string `sortable:"true"`
|
||||||
|
Token datatype.EncryptedString
|
||||||
|
LastSyncedAt *datatype.DateTime `sortable:"true"`
|
||||||
|
|
||||||
|
OidcClientID string
|
||||||
|
OidcClient OidcClient `gorm:"foreignKey:OidcClientID;references:ID;"`
|
||||||
|
}
|
||||||
91
backend/internal/model/types/encrypted_string.go
Normal file
91
backend/internal/model/types/encrypted_string.go
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
package datatype
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"database/sql/driver"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||||
|
cryptoutils "github.com/pocket-id/pocket-id/backend/internal/utils/crypto"
|
||||||
|
"golang.org/x/crypto/hkdf"
|
||||||
|
)
|
||||||
|
|
||||||
|
const encryptedStringAAD = "encrypted_string"
|
||||||
|
|
||||||
|
var encStringKey []byte
|
||||||
|
|
||||||
|
// EncryptedString stores plaintext in memory and persists encrypted data in the database.
|
||||||
|
type EncryptedString string //nolint:recvcheck
|
||||||
|
|
||||||
|
func (e *EncryptedString) Scan(value any) error {
|
||||||
|
if value == nil {
|
||||||
|
*e = ""
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var raw string
|
||||||
|
switch v := value.(type) {
|
||||||
|
case string:
|
||||||
|
raw = v
|
||||||
|
case []byte:
|
||||||
|
raw = string(v)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unexpected type for EncryptedString: %T", value)
|
||||||
|
}
|
||||||
|
|
||||||
|
if raw == "" {
|
||||||
|
*e = ""
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
encBytes, err := base64.StdEncoding.DecodeString(raw)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to decode encrypted string: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
decBytes, err := cryptoutils.Decrypt(encStringKey, encBytes, []byte(encryptedStringAAD))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to decrypt encrypted string: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
*e = EncryptedString(decBytes)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e EncryptedString) Value() (driver.Value, error) {
|
||||||
|
if e == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
encBytes, err := cryptoutils.Encrypt(encStringKey, []byte(e), []byte(encryptedStringAAD))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to encrypt string: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return base64.StdEncoding.EncodeToString(encBytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e EncryptedString) String() string {
|
||||||
|
return string(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
func deriveEncryptedStringKey(master []byte) ([]byte, error) {
|
||||||
|
const info = "pocketid/encrypted_string"
|
||||||
|
r := hkdf.New(sha256.New, master, nil, []byte(info))
|
||||||
|
|
||||||
|
key := make([]byte, 32)
|
||||||
|
if _, err := io.ReadFull(r, key); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return key, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
key, err := deriveEncryptedStringKey(common.EnvConfig.EncryptionKey)
|
||||||
|
if err != nil {
|
||||||
|
panic(fmt.Sprintf("failed to derive encrypted string key: %v", err))
|
||||||
|
}
|
||||||
|
encStringKey = key
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package model
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/go-webauthn/webauthn/protocol"
|
"github.com/go-webauthn/webauthn/protocol"
|
||||||
"github.com/go-webauthn/webauthn/webauthn"
|
"github.com/go-webauthn/webauthn/webauthn"
|
||||||
@@ -22,6 +23,7 @@ type User struct {
|
|||||||
Locale *string
|
Locale *string
|
||||||
LdapID *string
|
LdapID *string
|
||||||
Disabled bool `sortable:"true" filterable:"true"`
|
Disabled bool `sortable:"true" filterable:"true"`
|
||||||
|
UpdatedAt *datatype.DateTime
|
||||||
|
|
||||||
CustomClaims []CustomClaim
|
CustomClaims []CustomClaim
|
||||||
UserGroups []UserGroup `gorm:"many2many:user_groups_users;"`
|
UserGroups []UserGroup `gorm:"many2many:user_groups_users;"`
|
||||||
@@ -85,6 +87,13 @@ func (u User) Initials() string {
|
|||||||
return strings.ToUpper(first + last)
|
return strings.ToUpper(first + last)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (u User) LastModified() time.Time {
|
||||||
|
if u.UpdatedAt != nil {
|
||||||
|
return u.UpdatedAt.ToTime()
|
||||||
|
}
|
||||||
|
return u.CreatedAt.ToTime()
|
||||||
|
}
|
||||||
|
|
||||||
type OneTimeAccessToken struct {
|
type OneTimeAccessToken struct {
|
||||||
Base
|
Base
|
||||||
Token string
|
Token string
|
||||||
|
|||||||
@@ -1,11 +1,25 @@
|
|||||||
package model
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||||
|
)
|
||||||
|
|
||||||
type UserGroup struct {
|
type UserGroup struct {
|
||||||
Base
|
Base
|
||||||
FriendlyName string `sortable:"true"`
|
FriendlyName string `sortable:"true"`
|
||||||
Name string `sortable:"true"`
|
Name string `sortable:"true"`
|
||||||
LdapID *string
|
LdapID *string
|
||||||
|
UpdatedAt *datatype.DateTime
|
||||||
Users []User `gorm:"many2many:user_groups_users;"`
|
Users []User `gorm:"many2many:user_groups_users;"`
|
||||||
CustomClaims []CustomClaim
|
CustomClaims []CustomClaim
|
||||||
AllowedOidcClients []OidcClient `gorm:"many2many:oidc_clients_allowed_user_groups;"`
|
AllowedOidcClients []OidcClient `gorm:"many2many:oidc_clients_allowed_user_groups;"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (ug UserGroup) LastModified() time.Time {
|
||||||
|
if ug.UpdatedAt != nil {
|
||||||
|
return ug.UpdatedAt.ToTime()
|
||||||
|
}
|
||||||
|
return ug.CreatedAt.ToTime()
|
||||||
|
}
|
||||||
|
|||||||
@@ -98,6 +98,17 @@ func (s *TestService) SeedDatabase(baseURL string) error {
|
|||||||
DisplayName: "Craig Federighi",
|
DisplayName: "Craig Federighi",
|
||||||
IsAdmin: false,
|
IsAdmin: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Base: model.Base{
|
||||||
|
ID: "d9256384-98ad-49a7-bc58-99ad0b4dc23c",
|
||||||
|
},
|
||||||
|
Username: "eddy",
|
||||||
|
Email: utils.Ptr("eddy.cue@test.com"),
|
||||||
|
FirstName: "Eddy",
|
||||||
|
LastName: "Cue",
|
||||||
|
DisplayName: "Eddy Cue",
|
||||||
|
IsAdmin: false,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
for _, user := range users {
|
for _, user := range users {
|
||||||
if err := tx.Create(&user).Error; err != nil {
|
if err := tx.Create(&user).Error; err != nil {
|
||||||
@@ -209,6 +220,20 @@ func (s *TestService) SeedDatabase(baseURL string) error {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Base: model.Base{
|
||||||
|
ID: "c46d2090-37a0-4f2b-8748-6aa53b0c1afa",
|
||||||
|
},
|
||||||
|
Name: "SCIM Client",
|
||||||
|
Secret: "$2a$10$h4wfa8gI7zavDAxwzSq1sOwYU4e8DwK1XZ8ZweNnY5KzlJ3Iz.qdK", // nQbiuMRG7FpdK2EnDd5MBivWQeKFXohn
|
||||||
|
CallbackURLs: model.UrlList{"http://scimclient/auth/callback"},
|
||||||
|
CreatedByID: utils.Ptr(users[0].ID),
|
||||||
|
IsGroupRestricted: true,
|
||||||
|
AllowedUserGroups: []model.UserGroup{
|
||||||
|
userGroups[0],
|
||||||
|
userGroups[1],
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
for _, client := range oidcClients {
|
for _, client := range oidcClients {
|
||||||
if err := tx.Create(&client).Error; err != nil {
|
if err := tx.Create(&client).Error; err != nil {
|
||||||
|
|||||||
@@ -168,7 +168,7 @@ func (s *OidcService) Authorize(ctx context.Context, input dto.AuthorizeOidcClie
|
|||||||
return "", "", err
|
return "", "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
if !s.IsUserGroupAllowedToAuthorize(user, client) {
|
if !IsUserGroupAllowedToAuthorize(user, client) {
|
||||||
return "", "", &common.OidcAccessDeniedError{}
|
return "", "", &common.OidcAccessDeniedError{}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,7 +224,7 @@ func (s *OidcService) hasAuthorizedClientInternal(ctx context.Context, clientID,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// IsUserGroupAllowedToAuthorize checks if the user group of the user is allowed to authorize the client
|
// IsUserGroupAllowedToAuthorize checks if the user group of the user is allowed to authorize the client
|
||||||
func (s *OidcService) IsUserGroupAllowedToAuthorize(user model.User, client model.OidcClient) bool {
|
func IsUserGroupAllowedToAuthorize(user model.User, client model.OidcClient) bool {
|
||||||
if !client.IsGroupRestricted {
|
if !client.IsGroupRestricted {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -1325,7 +1325,7 @@ func (s *OidcService) VerifyDeviceCode(ctx context.Context, userCode string, use
|
|||||||
return fmt.Errorf("error finding user groups: %w", err)
|
return fmt.Errorf("error finding user groups: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !s.IsUserGroupAllowedToAuthorize(user, deviceAuth.Client) {
|
if !IsUserGroupAllowedToAuthorize(user, deviceAuth.Client) {
|
||||||
return &common.OidcAccessDeniedError{}
|
return &common.OidcAccessDeniedError{}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1829,7 +1829,7 @@ func (s *OidcService) GetClientPreview(ctx context.Context, clientID string, use
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if !s.IsUserGroupAllowedToAuthorize(user, client) {
|
if !IsUserGroupAllowedToAuthorize(user, client) {
|
||||||
return nil, &common.OidcAccessDeniedError{}
|
return nil, &common.OidcAccessDeniedError{}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1956,7 +1956,7 @@ func (s *OidcService) IsClientAccessibleToUser(ctx context.Context, clientID str
|
|||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return s.IsUserGroupAllowedToAuthorize(user, client), nil
|
return IsUserGroupAllowedToAuthorize(user, client), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var errLogoTooLarge = errors.New("logo is too large")
|
var errLogoTooLarge = errors.New("logo is too large")
|
||||||
@@ -2116,3 +2116,16 @@ func (s *OidcService) updateClientLogoType(ctx context.Context, clientID string,
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *OidcService) GetClientScimServiceProvider(ctx context.Context, clientID string) (model.ScimServiceProvider, error) {
|
||||||
|
var provider model.ScimServiceProvider
|
||||||
|
err := s.db.
|
||||||
|
WithContext(ctx).
|
||||||
|
First(&provider, "oidc_client_id = ?", clientID).
|
||||||
|
Error
|
||||||
|
if err != nil {
|
||||||
|
return model.ScimServiceProvider{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return provider, nil
|
||||||
|
}
|
||||||
|
|||||||
136
backend/internal/service/scim_scheduler_service.go
Normal file
136
backend/internal/service/scim_scheduler_service.go
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ScimSchedulerService schedules and triggers periodic synchronization
|
||||||
|
// of SCIM service providers. Each provider is tracked independently,
|
||||||
|
// and sync operations are run at or after their scheduled time.
|
||||||
|
type ScimSchedulerService struct {
|
||||||
|
scimService *ScimService
|
||||||
|
providerSyncTime map[string]time.Time
|
||||||
|
mu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewScimSchedulerService(ctx context.Context, scimService *ScimService) (*ScimSchedulerService, error) {
|
||||||
|
s := &ScimSchedulerService{
|
||||||
|
scimService: scimService,
|
||||||
|
providerSyncTime: make(map[string]time.Time),
|
||||||
|
}
|
||||||
|
|
||||||
|
err := s.start(ctx)
|
||||||
|
return s, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScheduleSync forces the given provider to be synced soon by
|
||||||
|
// moving its next scheduled time to 5 minutes from now.
|
||||||
|
func (s *ScimSchedulerService) ScheduleSync(providerID string) {
|
||||||
|
s.setSyncTime(providerID, 5*time.Minute)
|
||||||
|
}
|
||||||
|
|
||||||
|
// start initializes the scheduler and begins the synchronization loop.
|
||||||
|
// Syncs happen every hour by default, but ScheduleSync can be called to schedule a sync sooner.
|
||||||
|
func (s *ScimSchedulerService) start(ctx context.Context) error {
|
||||||
|
if err := s.refreshProviders(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
const (
|
||||||
|
syncCheckInterval = 5 * time.Second
|
||||||
|
providerRefreshDelay = time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
ticker := time.NewTicker(syncCheckInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
lastProviderRefresh := time.Now()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
// Runs every 5 seconds to check if any provider is due for sync
|
||||||
|
case <-ticker.C:
|
||||||
|
now := time.Now()
|
||||||
|
if now.Sub(lastProviderRefresh) >= providerRefreshDelay {
|
||||||
|
err := s.refreshProviders(ctx)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Error refreshing SCIM service providers",
|
||||||
|
slog.Any("error", err),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
lastProviderRefresh = now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var due []string
|
||||||
|
s.mu.RLock()
|
||||||
|
for providerID, syncTime := range s.providerSyncTime {
|
||||||
|
if !syncTime.After(now) {
|
||||||
|
due = append(due, providerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.mu.RUnlock()
|
||||||
|
|
||||||
|
s.syncProviders(ctx, due)
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ScimSchedulerService) refreshProviders(ctx context.Context) error {
|
||||||
|
providers, err := s.scimService.ListServiceProviders(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
inAHour := time.Now().Add(time.Hour)
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
for _, provider := range providers {
|
||||||
|
if _, exists := s.providerSyncTime[provider.ID]; !exists {
|
||||||
|
s.providerSyncTime[provider.ID] = inAHour
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ScimSchedulerService) syncProviders(ctx context.Context, providerIDs []string) {
|
||||||
|
for _, providerID := range providerIDs {
|
||||||
|
err := s.scimService.SyncServiceProvider(ctx, providerID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
// Remove the provider from the schedule if it no longer exists
|
||||||
|
s.mu.Lock()
|
||||||
|
delete(s.providerSyncTime, providerID)
|
||||||
|
s.mu.Unlock()
|
||||||
|
} else {
|
||||||
|
slog.Error("Error syncing SCIM client",
|
||||||
|
slog.String("provider_id", providerID),
|
||||||
|
slog.Any("error", err),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// A successful sync schedules the next sync in an hour
|
||||||
|
s.setSyncTime(providerID, time.Hour)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ScimSchedulerService) setSyncTime(providerID string, t time.Duration) {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.providerSyncTime[providerID] = time.Now().Add(t)
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
774
backend/internal/service/scim_service.go
Normal file
774
backend/internal/service/scim_service.go
Normal file
@@ -0,0 +1,774 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"path"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||||
|
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
scimUserSchema = "urn:ietf:params:scim:schemas:core:2.0:User"
|
||||||
|
scimGroupSchema = "urn:ietf:params:scim:schemas:core:2.0:Group"
|
||||||
|
scimContentType = "application/scim+json"
|
||||||
|
)
|
||||||
|
|
||||||
|
const scimErrorBodyLimit = 4096
|
||||||
|
|
||||||
|
type scimSyncAction int
|
||||||
|
|
||||||
|
const (
|
||||||
|
scimActionNone scimSyncAction = iota
|
||||||
|
scimActionCreated
|
||||||
|
scimActionUpdated
|
||||||
|
scimActionDeleted
|
||||||
|
)
|
||||||
|
|
||||||
|
type scimSyncStats struct {
|
||||||
|
Created int
|
||||||
|
Updated int
|
||||||
|
Deleted int
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScimService handles SCIM provisioning to external service providers.
|
||||||
|
type ScimService struct {
|
||||||
|
db *gorm.DB
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewScimService(db *gorm.DB, httpClient *http.Client) *ScimService {
|
||||||
|
if httpClient == nil {
|
||||||
|
httpClient = &http.Client{Timeout: 20 * time.Second}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ScimService{db: db, httpClient: httpClient}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ScimService) GetServiceProvider(
|
||||||
|
ctx context.Context,
|
||||||
|
serviceProviderID string,
|
||||||
|
) (model.ScimServiceProvider, error) {
|
||||||
|
var provider model.ScimServiceProvider
|
||||||
|
err := s.db.WithContext(ctx).
|
||||||
|
Preload("OidcClient").
|
||||||
|
Preload("OidcClient.AllowedUserGroups").
|
||||||
|
First(&provider, "id = ?", serviceProviderID).
|
||||||
|
Error
|
||||||
|
if err != nil {
|
||||||
|
return model.ScimServiceProvider{}, err
|
||||||
|
}
|
||||||
|
return provider, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ScimService) ListServiceProviders(ctx context.Context) ([]model.ScimServiceProvider, error) {
|
||||||
|
var providers []model.ScimServiceProvider
|
||||||
|
err := s.db.WithContext(ctx).
|
||||||
|
Preload("OidcClient").
|
||||||
|
Find(&providers).
|
||||||
|
Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return providers, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ScimService) CreateServiceProvider(
|
||||||
|
ctx context.Context,
|
||||||
|
input *dto.ScimServiceProviderCreateDTO) (model.ScimServiceProvider, error) {
|
||||||
|
provider := model.ScimServiceProvider{
|
||||||
|
Endpoint: input.Endpoint,
|
||||||
|
Token: datatype.EncryptedString(input.Token),
|
||||||
|
OidcClientID: input.OidcClientID,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.db.WithContext(ctx).Create(&provider).Error; err != nil {
|
||||||
|
return model.ScimServiceProvider{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return provider, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ScimService) UpdateServiceProvider(ctx context.Context,
|
||||||
|
serviceProviderID string,
|
||||||
|
input *dto.ScimServiceProviderCreateDTO,
|
||||||
|
) (model.ScimServiceProvider, error) {
|
||||||
|
var provider model.ScimServiceProvider
|
||||||
|
err := s.db.WithContext(ctx).
|
||||||
|
First(&provider, "id = ?", serviceProviderID).
|
||||||
|
Error
|
||||||
|
if err != nil {
|
||||||
|
return model.ScimServiceProvider{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
provider.Endpoint = input.Endpoint
|
||||||
|
provider.Token = datatype.EncryptedString(input.Token)
|
||||||
|
provider.OidcClientID = input.OidcClientID
|
||||||
|
|
||||||
|
if err := s.db.WithContext(ctx).Save(&provider).Error; err != nil {
|
||||||
|
return model.ScimServiceProvider{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return provider, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ScimService) DeleteServiceProvider(ctx context.Context, serviceProviderID string) error {
|
||||||
|
return s.db.WithContext(ctx).
|
||||||
|
Delete(&model.ScimServiceProvider{}, "id = ?", serviceProviderID).
|
||||||
|
Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ScimService) SyncServiceProvider(ctx context.Context, serviceProviderID string) error {
|
||||||
|
start := time.Now()
|
||||||
|
provider, err := s.GetServiceProvider(ctx, serviceProviderID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.InfoContext(ctx, "Syncing SCIM service provider",
|
||||||
|
slog.String("provider_id", provider.ID),
|
||||||
|
slog.String("oidc_client_id", provider.OidcClientID),
|
||||||
|
)
|
||||||
|
|
||||||
|
allowedGroupIDs := groupIDs(provider.OidcClient.AllowedUserGroups)
|
||||||
|
|
||||||
|
// Load users and groups that should be synced to the SCIM provider
|
||||||
|
groups, err := s.groupsForClient(ctx, provider.OidcClient, allowedGroupIDs)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
users, err := s.usersForClient(ctx, provider.OidcClient, allowedGroupIDs)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load users and groups that already exist in the SCIM provider
|
||||||
|
userResources, err := listScimResources[dto.ScimUser](s, ctx, provider, "/Users")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
groupResources, err := listScimResources[dto.ScimGroup](s, ctx, provider, "/Groups")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var errs []error
|
||||||
|
var userStats scimSyncStats
|
||||||
|
var groupStats scimSyncStats
|
||||||
|
|
||||||
|
// Sync users first, so that groups can reference them
|
||||||
|
if stats, err := s.syncUsers(ctx, provider, users, &userResources); err != nil {
|
||||||
|
errs = append(errs, err)
|
||||||
|
userStats = stats
|
||||||
|
} else {
|
||||||
|
userStats = stats
|
||||||
|
}
|
||||||
|
|
||||||
|
stats, err := s.syncGroups(ctx, provider, groups, groupResources.Resources, userResources.Resources)
|
||||||
|
if err != nil {
|
||||||
|
errs = append(errs, err)
|
||||||
|
groupStats = stats
|
||||||
|
} else {
|
||||||
|
groupStats = stats
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(errs) > 0 {
|
||||||
|
slog.WarnContext(ctx, "SCIM sync completed with errors",
|
||||||
|
slog.String("provider_id", provider.ID),
|
||||||
|
slog.Int("error_count", len(errs)),
|
||||||
|
slog.Int("users_created", userStats.Created),
|
||||||
|
slog.Int("users_updated", userStats.Updated),
|
||||||
|
slog.Int("users_deleted", userStats.Deleted),
|
||||||
|
slog.Int("groups_created", groupStats.Created),
|
||||||
|
slog.Int("groups_updated", groupStats.Updated),
|
||||||
|
slog.Int("groups_deleted", groupStats.Deleted),
|
||||||
|
slog.Duration("duration", time.Since(start)),
|
||||||
|
)
|
||||||
|
return errors.Join(errs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
provider.LastSyncedAt = utils.Ptr(datatype.DateTime(time.Now()))
|
||||||
|
if err := s.db.WithContext(ctx).Save(&provider).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.InfoContext(ctx, "SCIM sync completed",
|
||||||
|
slog.String("provider_id", provider.ID),
|
||||||
|
slog.Int("users_created", userStats.Created),
|
||||||
|
slog.Int("users_updated", userStats.Updated),
|
||||||
|
slog.Int("users_deleted", userStats.Deleted),
|
||||||
|
slog.Int("groups_created", groupStats.Created),
|
||||||
|
slog.Int("groups_updated", groupStats.Updated),
|
||||||
|
slog.Int("groups_deleted", groupStats.Deleted),
|
||||||
|
slog.Duration("duration", time.Since(start)),
|
||||||
|
)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ScimService) syncUsers(
|
||||||
|
ctx context.Context,
|
||||||
|
provider model.ScimServiceProvider,
|
||||||
|
users []model.User,
|
||||||
|
resourceList *dto.ScimListResponse[dto.ScimUser],
|
||||||
|
) (stats scimSyncStats, err error) {
|
||||||
|
var errs []error
|
||||||
|
|
||||||
|
// Update or create users
|
||||||
|
for _, u := range users {
|
||||||
|
existing := getResourceByExternalID[dto.ScimUser](u.ID, resourceList.Resources)
|
||||||
|
|
||||||
|
action, created, err := s.syncUser(ctx, provider, u, existing)
|
||||||
|
if created != nil && existing == nil {
|
||||||
|
resourceList.Resources = append(resourceList.Resources, *created)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
errs = append(errs, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update stats based on action taken by syncUser
|
||||||
|
switch action {
|
||||||
|
case scimActionCreated:
|
||||||
|
stats.Created++
|
||||||
|
case scimActionUpdated:
|
||||||
|
stats.Updated++
|
||||||
|
case scimActionDeleted:
|
||||||
|
stats.Deleted++
|
||||||
|
case scimActionNone:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete users that are present in SCIM provider but not locally.
|
||||||
|
userSet := make(map[string]struct{})
|
||||||
|
for _, u := range users {
|
||||||
|
userSet[u.ID] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, r := range resourceList.Resources {
|
||||||
|
if _, ok := userSet[r.ExternalID]; !ok {
|
||||||
|
if err := s.deleteScimResource(ctx, provider, "/Users/"+url.PathEscape(r.ID)); err != nil {
|
||||||
|
errs = append(errs, err)
|
||||||
|
} else {
|
||||||
|
stats.Deleted++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats, errors.Join(errs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ScimService) syncGroups(
|
||||||
|
ctx context.Context,
|
||||||
|
provider model.ScimServiceProvider,
|
||||||
|
groups []model.UserGroup,
|
||||||
|
remoteGroups []dto.ScimGroup,
|
||||||
|
userResources []dto.ScimUser,
|
||||||
|
) (stats scimSyncStats, err error) {
|
||||||
|
var errs []error
|
||||||
|
|
||||||
|
// Update or create groups
|
||||||
|
for _, g := range groups {
|
||||||
|
existing := getResourceByExternalID[dto.ScimGroup](g.ID, remoteGroups)
|
||||||
|
|
||||||
|
action, err := s.syncGroup(ctx, provider, g, existing, userResources)
|
||||||
|
if err != nil {
|
||||||
|
errs = append(errs, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update stats based on action taken by syncGroup
|
||||||
|
switch action {
|
||||||
|
case scimActionCreated:
|
||||||
|
stats.Created++
|
||||||
|
case scimActionUpdated:
|
||||||
|
stats.Updated++
|
||||||
|
case scimActionDeleted:
|
||||||
|
stats.Deleted++
|
||||||
|
case scimActionNone:
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete groups that are present in SCIM provider but not locally
|
||||||
|
groupSet := make(map[string]struct{})
|
||||||
|
for _, g := range groups {
|
||||||
|
groupSet[g.ID] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, r := range remoteGroups {
|
||||||
|
if _, ok := groupSet[r.ExternalID]; !ok {
|
||||||
|
if err := s.deleteScimResource(ctx, provider, "/Groups/"+url.PathEscape(r.GetID())); err != nil {
|
||||||
|
errs = append(errs, err)
|
||||||
|
} else {
|
||||||
|
stats.Deleted++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats, errors.Join(errs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ScimService) syncUser(ctx context.Context,
|
||||||
|
provider model.ScimServiceProvider,
|
||||||
|
user model.User,
|
||||||
|
userResource *dto.ScimUser,
|
||||||
|
) (scimSyncAction, *dto.ScimUser, error) {
|
||||||
|
// If user is not allowed for the client, delete it from SCIM provider
|
||||||
|
if userResource != nil && !IsUserGroupAllowedToAuthorize(user, provider.OidcClient) {
|
||||||
|
return scimActionDeleted, nil, s.deleteScimResource(ctx, provider, fmt.Sprintf("/Users/%s", url.PathEscape(userResource.ID)))
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := dto.ScimUser{
|
||||||
|
ScimResourceData: dto.ScimResourceData{
|
||||||
|
Schemas: []string{scimUserSchema},
|
||||||
|
ExternalID: user.ID,
|
||||||
|
},
|
||||||
|
UserName: user.Username,
|
||||||
|
Name: &dto.ScimName{
|
||||||
|
GivenName: user.FirstName,
|
||||||
|
FamilyName: user.LastName,
|
||||||
|
},
|
||||||
|
Display: user.DisplayName,
|
||||||
|
Active: !user.Disabled,
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.Email != nil {
|
||||||
|
payload.Emails = []dto.ScimEmail{{
|
||||||
|
Value: *user.Email,
|
||||||
|
Primary: true,
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the user exists on the SCIM provider, and it has been modified, update it
|
||||||
|
if userResource != nil {
|
||||||
|
if user.LastModified().Before(userResource.GetMeta().LastModified) {
|
||||||
|
return scimActionNone, nil, nil
|
||||||
|
}
|
||||||
|
path := fmt.Sprintf("/Users/%s", url.PathEscape(userResource.GetID()))
|
||||||
|
userResource, err := updateScimResource(s, ctx, provider, path, payload)
|
||||||
|
if err != nil {
|
||||||
|
return scimActionNone, nil, err
|
||||||
|
}
|
||||||
|
return scimActionUpdated, userResource, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, create a new SCIM user
|
||||||
|
userResource, err := createScimResource(s, ctx, provider, "/Users", payload)
|
||||||
|
if err != nil {
|
||||||
|
return scimActionNone, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return scimActionCreated, userResource, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ScimService) syncGroup(
|
||||||
|
ctx context.Context,
|
||||||
|
provider model.ScimServiceProvider,
|
||||||
|
group model.UserGroup,
|
||||||
|
groupResource *dto.ScimGroup,
|
||||||
|
userResources []dto.ScimUser,
|
||||||
|
) (scimSyncAction, error) {
|
||||||
|
// If group is not allowed for the client, delete it from SCIM provider
|
||||||
|
if groupResource != nil && !groupAllowedForClient(group.ID, provider.OidcClient) {
|
||||||
|
return scimActionDeleted, s.deleteScimResource(ctx, provider, fmt.Sprintf("/Groups/%s", url.PathEscape(groupResource.GetID())))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare group members
|
||||||
|
members := make([]dto.ScimGroupMember, len(group.Users))
|
||||||
|
for i, user := range group.Users {
|
||||||
|
userResource := getResourceByExternalID[dto.ScimUser](user.ID, userResources)
|
||||||
|
if userResource == nil {
|
||||||
|
// Groups depend on user IDs already being provisioned
|
||||||
|
return scimActionNone, fmt.Errorf("cannot sync group %s: user %s is not provisioned in SCIM provider", group.ID, user.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
members[i] = dto.ScimGroupMember{
|
||||||
|
Value: userResource.GetID(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
groupPayload := dto.ScimGroup{
|
||||||
|
ScimResourceData: dto.ScimResourceData{
|
||||||
|
Schemas: []string{scimGroupSchema},
|
||||||
|
ExternalID: group.ID,
|
||||||
|
},
|
||||||
|
Display: group.FriendlyName,
|
||||||
|
Members: members,
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the group exists on the SCIM provider, and it has been modified, update it
|
||||||
|
if groupResource != nil {
|
||||||
|
if group.LastModified().Before(groupResource.GetMeta().LastModified) {
|
||||||
|
return scimActionNone, nil
|
||||||
|
}
|
||||||
|
path := fmt.Sprintf("/Groups/%s", url.PathEscape(groupResource.GetID()))
|
||||||
|
_, err := updateScimResource(s, ctx, provider, path, groupPayload)
|
||||||
|
if err != nil {
|
||||||
|
return scimActionNone, err
|
||||||
|
}
|
||||||
|
return scimActionUpdated, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, create a new SCIM group
|
||||||
|
_, err := createScimResource(s, ctx, provider, "/Groups", groupPayload)
|
||||||
|
if err != nil {
|
||||||
|
return scimActionNone, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return scimActionCreated, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func groupAllowedForClient(groupID string, client model.OidcClient) bool {
|
||||||
|
if !client.IsGroupRestricted {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, allowedGroup := range client.AllowedUserGroups {
|
||||||
|
if allowedGroup.ID == groupID {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func groupIDs(groups []model.UserGroup) []string {
|
||||||
|
ids := make([]string, len(groups))
|
||||||
|
for i, g := range groups {
|
||||||
|
ids[i] = g.ID
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ScimService) groupsForClient(
|
||||||
|
ctx context.Context,
|
||||||
|
client model.OidcClient,
|
||||||
|
allowedGroupIDs []string,
|
||||||
|
) ([]model.UserGroup, error) {
|
||||||
|
var groups []model.UserGroup
|
||||||
|
|
||||||
|
query := s.db.WithContext(ctx).Preload("Users").Model(&model.UserGroup{})
|
||||||
|
if client.IsGroupRestricted {
|
||||||
|
if len(allowedGroupIDs) == 0 {
|
||||||
|
return groups, nil
|
||||||
|
}
|
||||||
|
query = query.Where("id IN ?", allowedGroupIDs)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := query.Find(&groups).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return groups, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ScimService) usersForClient(
|
||||||
|
ctx context.Context,
|
||||||
|
client model.OidcClient,
|
||||||
|
allowedGroupIDs []string,
|
||||||
|
) ([]model.User, error) {
|
||||||
|
var users []model.User
|
||||||
|
|
||||||
|
query := s.db.WithContext(ctx).Model(&model.User{})
|
||||||
|
if client.IsGroupRestricted {
|
||||||
|
if len(allowedGroupIDs) == 0 {
|
||||||
|
return users, nil
|
||||||
|
}
|
||||||
|
query = query.
|
||||||
|
Joins("JOIN user_groups_users ON users.id = user_groups_users.user_id").
|
||||||
|
Where("user_groups_users.user_group_id IN ?", allowedGroupIDs).
|
||||||
|
Select("users.*").
|
||||||
|
Distinct()
|
||||||
|
}
|
||||||
|
|
||||||
|
query = query.Preload("UserGroups")
|
||||||
|
|
||||||
|
if err := query.Find(&users).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return users, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getResourceByExternalID[T dto.ScimResource](externalID string, resource []T) *T {
|
||||||
|
for i := range resource {
|
||||||
|
if resource[i].GetExternalID() == externalID {
|
||||||
|
return &resource[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func listScimResources[T any](
|
||||||
|
s *ScimService,
|
||||||
|
ctx context.Context,
|
||||||
|
provider model.ScimServiceProvider,
|
||||||
|
path string,
|
||||||
|
) (result dto.ScimListResponse[T], err error) {
|
||||||
|
startIndex := 1
|
||||||
|
count := 1000
|
||||||
|
|
||||||
|
for {
|
||||||
|
// Use SCIM pagination to avoid missing resources on large providers
|
||||||
|
queryParams := map[string]string{
|
||||||
|
"startIndex": strconv.Itoa(startIndex),
|
||||||
|
"count": strconv.Itoa(count),
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := s.scimRequest(ctx, provider, http.MethodGet, path, nil, queryParams)
|
||||||
|
if err != nil {
|
||||||
|
return dto.ScimListResponse[T]{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ensureScimStatus(ctx, resp, provider, http.StatusOK); err != nil {
|
||||||
|
return dto.ScimListResponse[T]{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var page dto.ScimListResponse[T]
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&page); err != nil {
|
||||||
|
return dto.ScimListResponse[T]{}, fmt.Errorf("failed to decode SCIM list response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
// Initialize metadata only once
|
||||||
|
if result.TotalResults == 0 {
|
||||||
|
result.TotalResults = page.TotalResults
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Resources = append(result.Resources, page.Resources...)
|
||||||
|
|
||||||
|
// If we've fetched everything, stop
|
||||||
|
if len(result.Resources) >= page.TotalResults || len(page.Resources) == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
startIndex += page.ItemsPerPage
|
||||||
|
}
|
||||||
|
|
||||||
|
result.ItemsPerPage = len(result.Resources)
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func createScimResource[T dto.ScimResource](
|
||||||
|
s *ScimService,
|
||||||
|
ctx context.Context,
|
||||||
|
provider model.ScimServiceProvider,
|
||||||
|
path string, payload T) (*T, error) {
|
||||||
|
resp, err := s.scimRequest(ctx, provider, http.MethodPost, path, payload, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if err := ensureScimStatus(ctx, resp, provider, http.StatusOK, http.StatusCreated); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var resource T
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&resource); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode SCIM create response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &resource, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateScimResource[T dto.ScimResource](
|
||||||
|
s *ScimService,
|
||||||
|
ctx context.Context,
|
||||||
|
provider model.ScimServiceProvider,
|
||||||
|
path string,
|
||||||
|
payload T,
|
||||||
|
) (*T, error) {
|
||||||
|
resp, err := s.scimRequest(ctx, provider, http.MethodPut, path, payload, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if err := ensureScimStatus(ctx, resp, provider, http.StatusOK, http.StatusCreated); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var resource T
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&resource); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode SCIM update response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &resource, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ScimService) deleteScimResource(ctx context.Context, provider model.ScimServiceProvider, path string) error {
|
||||||
|
resp, err := s.scimRequest(ctx, provider, http.MethodDelete, path, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode == http.StatusNotFound {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return ensureScimStatus(ctx, resp, provider, http.StatusOK, http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ScimService) scimRequest(
|
||||||
|
ctx context.Context,
|
||||||
|
provider model.ScimServiceProvider,
|
||||||
|
method,
|
||||||
|
path string,
|
||||||
|
payload any,
|
||||||
|
queryParams map[string]string,
|
||||||
|
) (*http.Response, error) {
|
||||||
|
urlString, err := scimURL(provider.Endpoint, path, queryParams)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var bodyBytes []byte
|
||||||
|
if payload != nil {
|
||||||
|
encoded, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to encode SCIM payload: %w", err)
|
||||||
|
}
|
||||||
|
bodyBytes = encoded
|
||||||
|
}
|
||||||
|
|
||||||
|
retryAttempts := 3
|
||||||
|
for attempt := 1; attempt <= retryAttempts; attempt++ {
|
||||||
|
var body io.Reader
|
||||||
|
if bodyBytes != nil {
|
||||||
|
body = bytes.NewReader(bodyBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, method, urlString, body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Accept", scimContentType)
|
||||||
|
if payload != nil {
|
||||||
|
req.Header.Set("Content-Type", scimContentType)
|
||||||
|
}
|
||||||
|
token := string(provider.Token)
|
||||||
|
if token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Debug("Sending SCIM request",
|
||||||
|
slog.String("method", method),
|
||||||
|
slog.String("url", urlString),
|
||||||
|
slog.String("provider_id", provider.ID),
|
||||||
|
)
|
||||||
|
|
||||||
|
resp, err := s.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only retry on 429 to avoid masking other errors
|
||||||
|
if resp.StatusCode != http.StatusTooManyRequests || attempt == retryAttempts {
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
retryDelay := scimRetryDelay(resp.Header.Get("Retry-After"), attempt)
|
||||||
|
slog.WarnContext(ctx, "SCIM provider rate-limited, retrying",
|
||||||
|
slog.String("provider_id", provider.ID),
|
||||||
|
slog.String("method", method),
|
||||||
|
slog.String("url", urlString),
|
||||||
|
slog.Int("attempt", attempt),
|
||||||
|
slog.Duration("retry_after", retryDelay),
|
||||||
|
)
|
||||||
|
|
||||||
|
resp.Body.Close()
|
||||||
|
if err := utils.SleepWithContext(ctx, retryDelay); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("scim request retry attempts exceeded")
|
||||||
|
}
|
||||||
|
|
||||||
|
func scimRetryDelay(retryAfter string, attempt int) time.Duration {
|
||||||
|
// Respect Retry-After when provided
|
||||||
|
if retryAfter != "" {
|
||||||
|
if seconds, err := strconv.Atoi(retryAfter); err == nil {
|
||||||
|
return time.Duration(seconds) * time.Second
|
||||||
|
}
|
||||||
|
if t, err := http.ParseTime(retryAfter); err == nil {
|
||||||
|
if delay := time.Until(t); delay > 0 {
|
||||||
|
return delay
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exponential backoff otherwise
|
||||||
|
maxDelay := 10 * time.Second
|
||||||
|
delay := 500 * time.Millisecond * (time.Duration(1) << (attempt - 1)) //nolint:gosec // attempt is bounded 1-3
|
||||||
|
if delay > maxDelay {
|
||||||
|
return maxDelay
|
||||||
|
}
|
||||||
|
return delay
|
||||||
|
}
|
||||||
|
|
||||||
|
func scimURL(endpoint, p string, queryParams map[string]string) (string, error) {
|
||||||
|
u, err := url.Parse(endpoint)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("invalid scim endpoint: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
u.Path = path.Join(strings.TrimRight(u.Path, "/"), p)
|
||||||
|
|
||||||
|
q := u.Query()
|
||||||
|
for key, value := range queryParams {
|
||||||
|
q.Set(key, value)
|
||||||
|
}
|
||||||
|
u.RawQuery = q.Encode()
|
||||||
|
|
||||||
|
return u.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureScimStatus(
|
||||||
|
ctx context.Context,
|
||||||
|
resp *http.Response,
|
||||||
|
provider model.ScimServiceProvider,
|
||||||
|
allowedStatuses ...int) error {
|
||||||
|
for _, status := range allowedStatuses {
|
||||||
|
if resp.StatusCode == status {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body := readScimErrorBody(resp.Body)
|
||||||
|
|
||||||
|
slog.ErrorContext(ctx, "SCIM request failed",
|
||||||
|
slog.String("provider_id", provider.ID),
|
||||||
|
slog.String("method", resp.Request.Method),
|
||||||
|
slog.String("url", resp.Request.URL.String()),
|
||||||
|
slog.Int("status", resp.StatusCode),
|
||||||
|
slog.String("response_body", body),
|
||||||
|
)
|
||||||
|
|
||||||
|
return fmt.Errorf("scim request failed with status %d: %s", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func readScimErrorBody(body io.Reader) string {
|
||||||
|
payload, err := io.ReadAll(io.LimitReader(body, scimErrorBodyLimit))
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(payload))
|
||||||
|
}
|
||||||
@@ -3,7 +3,9 @@ package service
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||||
@@ -151,6 +153,7 @@ func (s *UserGroupService) updateInternal(ctx context.Context, id string, input
|
|||||||
|
|
||||||
group.Name = input.Name
|
group.Name = input.Name
|
||||||
group.FriendlyName = input.FriendlyName
|
group.FriendlyName = input.FriendlyName
|
||||||
|
group.UpdatedAt = utils.Ptr(datatype.DateTime(time.Now()))
|
||||||
|
|
||||||
err = tx.
|
err = tx.
|
||||||
WithContext(ctx).
|
WithContext(ctx).
|
||||||
@@ -214,6 +217,8 @@ func (s *UserGroupService) updateUsersInternal(ctx context.Context, id string, u
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Save the updated group
|
// Save the updated group
|
||||||
|
group.UpdatedAt = utils.Ptr(datatype.DateTime(time.Now()))
|
||||||
|
|
||||||
err = tx.
|
err = tx.
|
||||||
WithContext(ctx).
|
WithContext(ctx).
|
||||||
Save(&group).
|
Save(&group).
|
||||||
|
|||||||
@@ -426,6 +426,8 @@ func (s *UserService) updateUserInternal(ctx context.Context, userID string, upd
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
user.UpdatedAt = utils.Ptr(datatype.DateTime(time.Now()))
|
||||||
|
|
||||||
err = tx.
|
err = tx.
|
||||||
WithContext(ctx).
|
WithContext(ctx).
|
||||||
Save(&user).
|
Save(&user).
|
||||||
@@ -646,6 +648,16 @@ func (s *UserService) UpdateUserGroups(ctx context.Context, id string, userGroup
|
|||||||
return model.User{}, err
|
return model.User{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update the UpdatedAt field for all affected groups
|
||||||
|
now := time.Now()
|
||||||
|
for _, group := range groups {
|
||||||
|
group.UpdatedAt = utils.Ptr(datatype.DateTime(now))
|
||||||
|
err = tx.WithContext(ctx).Save(&group).Error
|
||||||
|
if err != nil {
|
||||||
|
return model.User{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
err = tx.Commit().Error
|
err = tx.Commit().Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return model.User{}, err
|
return model.User{}, err
|
||||||
|
|||||||
21
backend/internal/utils/sleep_util.go
Normal file
21
backend/internal/utils/sleep_util.go
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func SleepWithContext(ctx context.Context, delay time.Duration) error {
|
||||||
|
if delay <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
timer := time.NewTimer(delay)
|
||||||
|
defer timer.Stop()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-timer.C:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
DROP TABLE scim_service_providers;
|
||||||
|
ALTER TABLE users DROP COLUMN updated_at;
|
||||||
|
ALTER TABLE user_groups DROP COLUMN updated_at;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
CREATE TABLE scim_service_providers
|
||||||
|
(
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL,
|
||||||
|
endpoint TEXT NOT NULL,
|
||||||
|
token TEXT NOT NULL,
|
||||||
|
last_synced_at TIMESTAMPTZ,
|
||||||
|
oidc_client_id TEXT NOT NULL REFERENCES oidc_clients (id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN updated_at TIMESTAMPTZ;
|
||||||
|
|
||||||
|
ALTER TABLE user_groups
|
||||||
|
ADD COLUMN updated_at TIMESTAMPTZ;
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
PRAGMA foreign_keys=OFF;
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
DROP TABLE scim_service_providers;
|
||||||
|
ALTER TABLE users DROP COLUMN updated_at;
|
||||||
|
ALTER TABLE user_groups DROP COLUMN updated_at;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
PRAGMA foreign_keys=ON;
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
PRAGMA foreign_keys= OFF;
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
CREATE TABLE scim_service_providers
|
||||||
|
(
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
created_at DATETIME NOT NULL,
|
||||||
|
endpoint TEXT NOT NULL,
|
||||||
|
token TEXT NOT NULL,
|
||||||
|
last_synced_at DATETIME,
|
||||||
|
oidc_client_id TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (oidc_client_id) REFERENCES oidc_clients (id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN updated_at DATETIME;
|
||||||
|
|
||||||
|
ALTER TABLE user_groups
|
||||||
|
ADD COLUMN updated_at DATETIME;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
PRAGMA foreign_keys= ON;
|
||||||
@@ -484,5 +484,19 @@
|
|||||||
"yes": "Yes",
|
"yes": "Yes",
|
||||||
"no": "No",
|
"no": "No",
|
||||||
"restricted": "Restricted",
|
"restricted": "Restricted",
|
||||||
|
"scim_provisioning": "SCIM Provisioning",
|
||||||
|
"scim_provisioning_description": "SCIM provisioning allows you to automatically provision and deprovision users and groups from your OIDC client. Learn more in the <link href='https://pocket-id.org/docs/configuration/scim'>docs</link>.",
|
||||||
|
"scim_endpoint": "SCIM Endpoint",
|
||||||
|
"scim_token": "SCIM Token",
|
||||||
|
"last_successful_sync_at": "Last successful sync: {time}",
|
||||||
|
"scim_configuration_updated_successfully": "SCIM configuration updated successfully.",
|
||||||
|
"scim_enabled_successfully": "SCIM enabled successfully.",
|
||||||
|
"scim_disabled_successfully": "SCIM disabled successfully.",
|
||||||
|
"disable_scim_provisioning": "Disable SCIM Provisioning",
|
||||||
|
"disable_scim_provisioning_confirm_description": "Are you sure you want to disable SCIM provisioning for <b>{clientName}</b>? This will stop all automatic user and group provisioning and deprovisioning.",
|
||||||
|
"scim_sync_failed": "SCIM sync failed. Check the server logs for more information.",
|
||||||
|
"scim_sync_successful": "The SCIM sync has been completed successfully.",
|
||||||
|
"save_and_sync": "Save and Sync",
|
||||||
|
"scim_save_changes_description": "You have to save the changes before starting a SCIM sync. Do you want to save now?",
|
||||||
"scopes": "Scopes"
|
"scopes": "Scopes"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import { LucideChevronDown, type Icon as IconType } from '@lucide/svelte';
|
import { LucideChevronDown, type Icon as IconType } from '@lucide/svelte';
|
||||||
import { onMount, type Snippet } from 'svelte';
|
import { onMount, type Snippet } from 'svelte';
|
||||||
import { slide } from 'svelte/transition';
|
import { slide } from 'svelte/transition';
|
||||||
|
import FormattedMessage from './formatted-message.svelte';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
import * as Card from './ui/card';
|
import * as Card from './ui/card';
|
||||||
|
|
||||||
@@ -70,7 +71,7 @@
|
|||||||
{title}
|
{title}
|
||||||
</Card.Title>
|
</Card.Title>
|
||||||
{#if description}
|
{#if description}
|
||||||
<Card.Description>{description}</Card.Description>
|
<Card.Description><FormattedMessage m={description} /></Card.Description>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if button}
|
{#if button}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type {
|
|||||||
OidcClientWithAllowedUserGroupsCount,
|
OidcClientWithAllowedUserGroupsCount,
|
||||||
OidcDeviceCodeInfo
|
OidcDeviceCodeInfo
|
||||||
} from '$lib/types/oidc.type';
|
} from '$lib/types/oidc.type';
|
||||||
|
import type { ScimServiceProvider } from '$lib/types/scim.type';
|
||||||
import { cachedOidcClientLogo } from '$lib/utils/cached-image-util';
|
import { cachedOidcClientLogo } from '$lib/utils/cached-image-util';
|
||||||
import APIService from './api-service';
|
import APIService from './api-service';
|
||||||
|
|
||||||
@@ -127,6 +128,11 @@ class OidcService extends APIService {
|
|||||||
revokeOwnAuthorizedClient = async (clientId: string) => {
|
revokeOwnAuthorizedClient = async (clientId: string) => {
|
||||||
await this.api.delete(`/oidc/users/me/authorized-clients/${clientId}`);
|
await this.api.delete(`/oidc/users/me/authorized-clients/${clientId}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
getScimResourceProvider = async (clientId: string) => {
|
||||||
|
const res = await this.api.get(`/oidc/clients/${clientId}/scim-service-provider`);
|
||||||
|
return res.data as ScimServiceProvider;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default OidcService;
|
export default OidcService;
|
||||||
|
|||||||
27
frontend/src/lib/services/scim-service.ts
Normal file
27
frontend/src/lib/services/scim-service.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import type { ScimServiceProvider, ScimServiceProviderCreate } from '$lib/types/scim.type';
|
||||||
|
import APIService from './api-service';
|
||||||
|
|
||||||
|
class ScimService extends APIService {
|
||||||
|
syncServiceProvider = async (serviceProviderId: string) => {
|
||||||
|
return await this.api.post(`/scim/service-provider/${serviceProviderId}/sync`);
|
||||||
|
};
|
||||||
|
|
||||||
|
createServiceProvider = async (serviceProvider: ScimServiceProviderCreate) => {
|
||||||
|
return (await this.api.post('/scim/service-provider', serviceProvider))
|
||||||
|
.data as ScimServiceProvider;
|
||||||
|
};
|
||||||
|
|
||||||
|
updateServiceProvider = async (
|
||||||
|
serviceProviderId: string,
|
||||||
|
serviceProvider: ScimServiceProviderCreate
|
||||||
|
) => {
|
||||||
|
return (await this.api.put(`/scim/service-provider/${serviceProviderId}`, serviceProvider))
|
||||||
|
.data as ScimServiceProvider;
|
||||||
|
};
|
||||||
|
|
||||||
|
deleteServiceProvider = async (serviceProviderId: string) => {
|
||||||
|
await this.api.delete(`/scim/service-provider/${serviceProviderId}`);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ScimService;
|
||||||
14
frontend/src/lib/types/scim.type.ts
Normal file
14
frontend/src/lib/types/scim.type.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import type { OidcClientMetaData } from './oidc.type';
|
||||||
|
|
||||||
|
export type ScimServiceProvider = {
|
||||||
|
id: string;
|
||||||
|
endpoint: string;
|
||||||
|
token?: string;
|
||||||
|
lastSyncedAt?: string;
|
||||||
|
createdAt: string;
|
||||||
|
oidcClient: OidcClientMetaData;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ScimServiceProviderCreate = Pick<ScimServiceProvider, 'endpoint' | 'token'> & {
|
||||||
|
oidcClientId: string;
|
||||||
|
};
|
||||||
@@ -10,8 +10,10 @@
|
|||||||
import UserGroupSelection from '$lib/components/user-group-selection.svelte';
|
import UserGroupSelection from '$lib/components/user-group-selection.svelte';
|
||||||
import { m } from '$lib/paraglide/messages';
|
import { m } from '$lib/paraglide/messages';
|
||||||
import OidcService from '$lib/services/oidc-service';
|
import OidcService from '$lib/services/oidc-service';
|
||||||
|
import ScimService from '$lib/services/scim-service';
|
||||||
import clientSecretStore from '$lib/stores/client-secret-store';
|
import clientSecretStore from '$lib/stores/client-secret-store';
|
||||||
import type { OidcClientCreateWithLogo } from '$lib/types/oidc.type';
|
import type { OidcClientCreateWithLogo } from '$lib/types/oidc.type';
|
||||||
|
import type { ScimServiceProviderCreate } from '$lib/types/scim.type';
|
||||||
import { axiosErrorToast } from '$lib/utils/error-util';
|
import { axiosErrorToast } from '$lib/utils/error-util';
|
||||||
import { LucideChevronLeft, LucideRefreshCcw } from '@lucide/svelte';
|
import { LucideChevronLeft, LucideRefreshCcw } from '@lucide/svelte';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
@@ -19,16 +21,20 @@
|
|||||||
import { backNavigate } from '../../users/navigate-back-util';
|
import { backNavigate } from '../../users/navigate-back-util';
|
||||||
import OidcForm from '../oidc-client-form.svelte';
|
import OidcForm from '../oidc-client-form.svelte';
|
||||||
import OidcClientPreviewModal from '../oidc-client-preview-modal.svelte';
|
import OidcClientPreviewModal from '../oidc-client-preview-modal.svelte';
|
||||||
|
import ScimResourceProviderForm from './scim-resource-provider-form.svelte';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
let client = $state({
|
let client = $state({
|
||||||
...data,
|
...data.client,
|
||||||
allowedUserGroupIds: data.allowedUserGroups.map((g) => g.id)
|
allowedUserGroupIds: data.client.allowedUserGroups.map((g) => g.id)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let scimServiceProvider = $state(data.scimServiceProvider);
|
||||||
let showAllDetails = $state(false);
|
let showAllDetails = $state(false);
|
||||||
let showPreview = $state(false);
|
let showPreview = $state(false);
|
||||||
|
|
||||||
const oidcService = new OidcService();
|
const oidcService = new OidcService();
|
||||||
|
const scimService = new ScimService();
|
||||||
const backNavigation = backNavigate('/settings/admin/oidc-clients');
|
const backNavigation = backNavigate('/settings/admin/oidc-clients');
|
||||||
|
|
||||||
const setupDetails = $state({
|
const setupDetails = $state({
|
||||||
@@ -149,6 +155,30 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function saveScimServiceProvider(provider: ScimServiceProviderCreate | null) {
|
||||||
|
try {
|
||||||
|
if (!provider) {
|
||||||
|
await scimService.deleteServiceProvider(scimServiceProvider!.id);
|
||||||
|
scimServiceProvider = undefined;
|
||||||
|
toast.success(m.scim_disabled_successfully());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let createdProvider;
|
||||||
|
if (scimServiceProvider) {
|
||||||
|
createdProvider = await scimService.updateServiceProvider(scimServiceProvider.id, provider);
|
||||||
|
toast.success(m.scim_configuration_updated_successfully());
|
||||||
|
} else {
|
||||||
|
createdProvider = await scimService.createServiceProvider(provider);
|
||||||
|
toast.success(m.scim_enabled_successfully());
|
||||||
|
}
|
||||||
|
scimServiceProvider = createdProvider;
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
axiosErrorToast(e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
beforeNavigate(() => {
|
beforeNavigate(() => {
|
||||||
clientSecretStore.clear();
|
clientSecretStore.clear();
|
||||||
});
|
});
|
||||||
@@ -251,9 +281,22 @@
|
|||||||
<div class="mt-5 flex justify-end gap-3">
|
<div class="mt-5 flex justify-end gap-3">
|
||||||
<Button onclick={disableGroupRestriction} variant="secondary">{m.unrestrict()}</Button>
|
<Button onclick={disableGroupRestriction} variant="secondary">{m.unrestrict()}</Button>
|
||||||
|
|
||||||
<Button onclick={() => updateUserGroupClients(client.allowedUserGroupIds)}>{m.save()}</Button>
|
<Button usePromiseLoading onclick={() => updateUserGroupClients(client.allowedUserGroupIds)}
|
||||||
|
>{m.save()}</Button
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</CollapsibleCard>
|
</CollapsibleCard>
|
||||||
|
<CollapsibleCard
|
||||||
|
id="scim-provisioning"
|
||||||
|
title={m.scim_provisioning()}
|
||||||
|
description={m.scim_provisioning_description()}
|
||||||
|
>
|
||||||
|
<ScimResourceProviderForm
|
||||||
|
oidcClientId={client.id}
|
||||||
|
existingProvider={scimServiceProvider}
|
||||||
|
onSave={saveScimServiceProvider}
|
||||||
|
/>
|
||||||
|
</CollapsibleCard>
|
||||||
<Card.Root>
|
<Card.Root>
|
||||||
<Card.Header>
|
<Card.Header>
|
||||||
<div class="flex flex-col items-start justify-between gap-3 sm:flex-row sm:items-center">
|
<div class="flex flex-col items-start justify-between gap-3 sm:flex-row sm:items-center">
|
||||||
|
|||||||
@@ -3,5 +3,14 @@ import type { PageLoad } from './$types';
|
|||||||
|
|
||||||
export const load: PageLoad = async ({ params }) => {
|
export const load: PageLoad = async ({ params }) => {
|
||||||
const oidcService = new OidcService();
|
const oidcService = new OidcService();
|
||||||
return await oidcService.getClient(params.id);
|
|
||||||
|
const client = await oidcService.getClient(params.id);
|
||||||
|
const scimServiceProvider = await oidcService
|
||||||
|
.getScimResourceProvider(params.id)
|
||||||
|
.then((p) => p)
|
||||||
|
.catch(() => undefined);
|
||||||
|
return {
|
||||||
|
client,
|
||||||
|
scimServiceProvider
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { openConfirmDialog } from '$lib/components/confirm-dialog';
|
||||||
|
import FormInput from '$lib/components/form/form-input.svelte';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { m } from '$lib/paraglide/messages';
|
||||||
|
import ScimService from '$lib/services/scim-service';
|
||||||
|
import type { ScimServiceProvider, ScimServiceProviderCreate } from '$lib/types/scim.type';
|
||||||
|
import { preventDefault } from '$lib/utils/event-util';
|
||||||
|
import { createForm } from '$lib/utils/form-util';
|
||||||
|
import { emptyToUndefined } from '$lib/utils/zod-util';
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
import { z } from 'zod/v4';
|
||||||
|
|
||||||
|
let {
|
||||||
|
onSave,
|
||||||
|
existingProvider,
|
||||||
|
oidcClientId
|
||||||
|
}: {
|
||||||
|
existingProvider?: ScimServiceProvider;
|
||||||
|
onSave: (provider: ScimServiceProviderCreate | null) => Promise<boolean>;
|
||||||
|
oidcClientId: string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const scimService = new ScimService();
|
||||||
|
|
||||||
|
let isSyncing = $state(false);
|
||||||
|
|
||||||
|
const serviceProvider = {
|
||||||
|
endpoint: existingProvider?.endpoint || '',
|
||||||
|
token: existingProvider?.token || ''
|
||||||
|
};
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
endpoint: z.url(),
|
||||||
|
token: emptyToUndefined(z.string())
|
||||||
|
});
|
||||||
|
type FormSchema = typeof formSchema;
|
||||||
|
|
||||||
|
const { inputs, ...form } = createForm<FormSchema>(formSchema, serviceProvider);
|
||||||
|
|
||||||
|
async function onSubmit() {
|
||||||
|
const data = form.validate();
|
||||||
|
if (!data) return false;
|
||||||
|
return await onSave({
|
||||||
|
...data,
|
||||||
|
oidcClientId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDisable() {
|
||||||
|
openConfirmDialog({
|
||||||
|
title: m.disable_scim_provisioning(),
|
||||||
|
message: m.disable_scim_provisioning_confirm_description({
|
||||||
|
clientName: existingProvider!.oidcClient.name
|
||||||
|
}),
|
||||||
|
confirm: {
|
||||||
|
label: m.disable(),
|
||||||
|
destructive: true,
|
||||||
|
action: async () => {
|
||||||
|
await onSave(null);
|
||||||
|
form.setValue('endpoint', '');
|
||||||
|
form.setValue('token', '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSync() {
|
||||||
|
const hasChanges = Object.keys($inputs).some(
|
||||||
|
// @ts-ignore
|
||||||
|
(key) => $inputs[key].value !== (existingProvider as any)[key]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasChanges) {
|
||||||
|
openConfirmDialog({
|
||||||
|
title: m.save_changes_question(),
|
||||||
|
message: m.scim_save_changes_description(),
|
||||||
|
confirm: {
|
||||||
|
label: m.save_and_sync(),
|
||||||
|
action: async () => {
|
||||||
|
const saved = await onSubmit();
|
||||||
|
if (saved) {
|
||||||
|
syncProvider();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
syncProvider();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncProvider() {
|
||||||
|
isSyncing = true;
|
||||||
|
await scimService
|
||||||
|
.syncServiceProvider(existingProvider!.id)
|
||||||
|
.then(() => {
|
||||||
|
existingProvider = {
|
||||||
|
...existingProvider!,
|
||||||
|
lastSyncedAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
toast.success(m.scim_sync_successful());
|
||||||
|
})
|
||||||
|
.catch(() => toast.error(m.scim_sync_failed()))
|
||||||
|
.finally(() => (isSyncing = false));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<form onsubmit={preventDefault(onSubmit)}>
|
||||||
|
<div class="flex flex-col gap-3 sm:flex-row">
|
||||||
|
<div class="w-full">
|
||||||
|
<FormInput
|
||||||
|
placeholder="https://scim.example.com/v2"
|
||||||
|
label={m.scim_endpoint()}
|
||||||
|
bind:input={$inputs.endpoint}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="w-full">
|
||||||
|
<FormInput label={m.scim_token()} bind:input={$inputs.token} type="password" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="mt-5 flex items-end flex-col sm:flex-row {existingProvider
|
||||||
|
? 'justify-between'
|
||||||
|
: 'justify-end'} "
|
||||||
|
>
|
||||||
|
{#if existingProvider}
|
||||||
|
<p class="text-muted-foreground text-xs self-start sm:self-auto">
|
||||||
|
{m.last_successful_sync_at({
|
||||||
|
time: existingProvider.lastSyncedAt
|
||||||
|
? new Date(existingProvider.lastSyncedAt).toLocaleString()
|
||||||
|
: m.never()
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
<div class="mt-5 flex justify-end gap-3">
|
||||||
|
{#if existingProvider}
|
||||||
|
<Button variant="destructive" onclick={onDisable}>{m.disable()}</Button>
|
||||||
|
<Button variant="secondary" isLoading={isSyncing} onclick={onSync}>{m.sync_now()}</Button>
|
||||||
|
{/if}
|
||||||
|
<Button type="submit">{existingProvider ? m.save() : m.enable()}</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
@@ -56,6 +56,12 @@ export const oidcClients = {
|
|||||||
},
|
},
|
||||||
accessCodes: ['federated']
|
accessCodes: ['federated']
|
||||||
},
|
},
|
||||||
|
scim: {
|
||||||
|
id: 'c46d2090-37a0-4f2b-8748-6aa53b0c1afa',
|
||||||
|
name: 'SCIM Client',
|
||||||
|
callbackUrl: 'http://scim.client/auth/callback',
|
||||||
|
secret: 'nQbiuMRG7FpdK2EnDd5MBivWQeKFXohn'
|
||||||
|
},
|
||||||
pingvinShare: {
|
pingvinShare: {
|
||||||
name: 'Pingvin Share',
|
name: 'Pingvin Share',
|
||||||
callbackUrl: 'http://pingvin.share/auth/callback',
|
callbackUrl: 'http://pingvin.share/auth/callback',
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"provider": "sqlite",
|
"provider": "sqlite",
|
||||||
"version": 20251219000000,
|
"version": 20251229173100,
|
||||||
"tableOrder": ["users", "user_groups", "oidc_clients", "signup_tokens"],
|
"tableOrder": ["users", "user_groups", "oidc_clients", "signup_tokens"],
|
||||||
"tables": {
|
"tables": {
|
||||||
"api_keys": [
|
"api_keys": [
|
||||||
@@ -122,12 +122,36 @@
|
|||||||
"pkce_enabled": false,
|
"pkce_enabled": false,
|
||||||
"requires_reauthentication": false,
|
"requires_reauthentication": false,
|
||||||
"secret": "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe"
|
"secret": "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"callback_urls": "WyJodHRwOi8vc2NpbWNsaWVudC9hdXRoL2NhbGxiYWNrIl0=",
|
||||||
|
"created_at": "2025-11-25T12:39:02Z",
|
||||||
|
"created_by_id": "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e",
|
||||||
|
"dark_image_type": null,
|
||||||
|
"id": "c46d2090-37a0-4f2b-8748-6aa53b0c1afa",
|
||||||
|
"image_type": null,
|
||||||
|
"is_group_restricted": true,
|
||||||
|
"is_public": false,
|
||||||
|
"launch_url": null,
|
||||||
|
"logout_callback_urls": "bnVsbA==",
|
||||||
|
"name": "SCIM Client",
|
||||||
|
"pkce_enabled": false,
|
||||||
|
"requires_reauthentication": false,
|
||||||
|
"secret": "$2a$10$h4wfa8gI7zavDAxwzSq1sOwYU4e8DwK1XZ8ZweNnY5KzlJ3Iz.qdK"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"oidc_clients_allowed_user_groups": [
|
"oidc_clients_allowed_user_groups": [
|
||||||
{
|
{
|
||||||
"oidc_client_id": "606c7782-f2b1-49e5-8ea9-26eb1b06d018",
|
"oidc_client_id": "606c7782-f2b1-49e5-8ea9-26eb1b06d018",
|
||||||
"user_group_id": "adab18bf-f89d-4087-9ee1-70ff15b48211"
|
"user_group_id": "adab18bf-f89d-4087-9ee1-70ff15b48211"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"oidc_client_id": "c46d2090-37a0-4f2b-8748-6aa53b0c1afa",
|
||||||
|
"user_group_id": "adab18bf-f89d-4087-9ee1-70ff15b48211"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"oidc_client_id": "c46d2090-37a0-4f2b-8748-6aa53b0c1afa",
|
||||||
|
"user_group_id": "c7ae7c01-28a3-4f3c-9572-1ee734ea8368"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"oidc_refresh_tokens": [
|
"oidc_refresh_tokens": [
|
||||||
@@ -230,6 +254,7 @@
|
|||||||
"user_groups": [
|
"user_groups": [
|
||||||
{
|
{
|
||||||
"created_at": "2025-11-25T12:39:02Z",
|
"created_at": "2025-11-25T12:39:02Z",
|
||||||
|
"updated_at": null,
|
||||||
"friendly_name": "Developers",
|
"friendly_name": "Developers",
|
||||||
"id": "c7ae7c01-28a3-4f3c-9572-1ee734ea8368",
|
"id": "c7ae7c01-28a3-4f3c-9572-1ee734ea8368",
|
||||||
"ldap_id": null,
|
"ldap_id": null,
|
||||||
@@ -237,6 +262,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"created_at": "2025-11-25T12:39:02Z",
|
"created_at": "2025-11-25T12:39:02Z",
|
||||||
|
"updated_at": null,
|
||||||
"friendly_name": "Designers",
|
"friendly_name": "Designers",
|
||||||
"id": "adab18bf-f89d-4087-9ee1-70ff15b48211",
|
"id": "adab18bf-f89d-4087-9ee1-70ff15b48211",
|
||||||
"ldap_id": null,
|
"ldap_id": null,
|
||||||
@@ -260,6 +286,7 @@
|
|||||||
"users": [
|
"users": [
|
||||||
{
|
{
|
||||||
"created_at": "2025-11-25T12:39:02Z",
|
"created_at": "2025-11-25T12:39:02Z",
|
||||||
|
"updated_at": null,
|
||||||
"disabled": false,
|
"disabled": false,
|
||||||
"display_name": "Tim Cook",
|
"display_name": "Tim Cook",
|
||||||
"email": "tim.cook@test.com",
|
"email": "tim.cook@test.com",
|
||||||
@@ -273,6 +300,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"created_at": "2025-11-25T12:39:02Z",
|
"created_at": "2025-11-25T12:39:02Z",
|
||||||
|
"updated_at": null,
|
||||||
"disabled": false,
|
"disabled": false,
|
||||||
"display_name": "Craig Federighi",
|
"display_name": "Craig Federighi",
|
||||||
"email": "craig.federighi@test.com",
|
"email": "craig.federighi@test.com",
|
||||||
@@ -283,6 +311,20 @@
|
|||||||
"ldap_id": null,
|
"ldap_id": null,
|
||||||
"locale": null,
|
"locale": null,
|
||||||
"username": "craig"
|
"username": "craig"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"created_at": "2025-11-25T12:39:02Z",
|
||||||
|
"updated_at": null,
|
||||||
|
"disabled": false,
|
||||||
|
"display_name": "Eddy Cue",
|
||||||
|
"email": "eddy.cue@test.com",
|
||||||
|
"first_name": "Eddy",
|
||||||
|
"id": "d9256384-98ad-49a7-bc58-99ad0b4dc23c",
|
||||||
|
"is_admin": false,
|
||||||
|
"last_name": "Cue",
|
||||||
|
"ldap_id": null,
|
||||||
|
"locale": null,
|
||||||
|
"username": "eddy"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"webauthn_credentials": [
|
"webauthn_credentials": [
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ services:
|
|||||||
extends:
|
extends:
|
||||||
file: docker-compose.yml
|
file: docker-compose.yml
|
||||||
service: lldap
|
service: lldap
|
||||||
|
scim-test-server:
|
||||||
|
extends:
|
||||||
|
file: docker-compose.yml
|
||||||
|
service: scim-test-server
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:17
|
image: postgres:17
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ services:
|
|||||||
extends:
|
extends:
|
||||||
file: docker-compose.yml
|
file: docker-compose.yml
|
||||||
service: lldap
|
service: lldap
|
||||||
|
scim-test-server:
|
||||||
|
extends:
|
||||||
|
file: docker-compose.yml
|
||||||
|
service: scim-test-server
|
||||||
localstack-s3:
|
localstack-s3:
|
||||||
image: localstack/localstack:s3-latest
|
image: localstack/localstack:s3-latest
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -11,7 +14,6 @@ services:
|
|||||||
interval: 1s
|
interval: 1s
|
||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 10
|
retries: 10
|
||||||
|
|
||||||
create-bucket:
|
create-bucket:
|
||||||
image: amazon/aws-cli:latest
|
image: amazon/aws-cli:latest
|
||||||
environment:
|
environment:
|
||||||
@@ -22,7 +24,6 @@ services:
|
|||||||
localstack-s3:
|
localstack-s3:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
entrypoint: "aws --endpoint-url=http://localstack-s3:4566 s3 mb s3://pocket-id-test"
|
entrypoint: "aws --endpoint-url=http://localstack-s3:4566 s3 mb s3://pocket-id-test"
|
||||||
|
|
||||||
pocket-id:
|
pocket-id:
|
||||||
extends:
|
extends:
|
||||||
file: docker-compose.yml
|
file: docker-compose.yml
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ services:
|
|||||||
- LLDAP_JWT_SECRET=secret
|
- LLDAP_JWT_SECRET=secret
|
||||||
- LLDAP_LDAP_USER_PASS=admin_password
|
- LLDAP_LDAP_USER_PASS=admin_password
|
||||||
- LLDAP_LDAP_BASE_DN=dc=pocket-id,dc=org
|
- LLDAP_LDAP_BASE_DN=dc=pocket-id,dc=org
|
||||||
|
scim-test-server:
|
||||||
|
image: ghcr.io/pocket-id/scim-test-server:latest
|
||||||
|
ports:
|
||||||
|
- "18123:8080"
|
||||||
pocket-id:
|
pocket-id:
|
||||||
image: pocket-id:test
|
image: pocket-id:test
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ test('Dashboard shows all clients in the correct order', async ({ page }) => {
|
|||||||
|
|
||||||
await page.goto('/settings/apps');
|
await page.goto('/settings/apps');
|
||||||
|
|
||||||
await expect(page.getByTestId('authorized-oidc-client-card')).toHaveCount(4);
|
await expect(page.getByTestId('authorized-oidc-client-card')).toHaveCount(5);
|
||||||
|
|
||||||
// Should be first
|
// Should be first
|
||||||
const card1 = page.getByTestId('authorized-oidc-client-card').first();
|
const card1 = page.getByTestId('authorized-oidc-client-card').first();
|
||||||
@@ -32,7 +32,7 @@ test.describe('Dashboard shows only clients where user has access', () => {
|
|||||||
|
|
||||||
const cards = page.getByTestId('authorized-oidc-client-card');
|
const cards = page.getByTestId('authorized-oidc-client-card');
|
||||||
|
|
||||||
await expect(cards).toHaveCount(3);
|
await expect(cards).toHaveCount(4);
|
||||||
|
|
||||||
const cardTexts = await cards.allTextContents();
|
const cardTexts = await cards.allTextContents();
|
||||||
expect(cardTexts.some((text) => text.includes(notVisibleClient.name))).toBe(false);
|
expect(cardTexts.some((text) => text.includes(notVisibleClient.name))).toBe(false);
|
||||||
@@ -40,7 +40,7 @@ test.describe('Dashboard shows only clients where user has access', () => {
|
|||||||
test('User can see all clients', async ({ page }) => {
|
test('User can see all clients', async ({ page }) => {
|
||||||
await page.goto('/settings/apps');
|
await page.goto('/settings/apps');
|
||||||
const cards = page.getByTestId('authorized-oidc-client-card');
|
const cards = page.getByTestId('authorized-oidc-client-card');
|
||||||
await expect(cards).toHaveCount(4);
|
await expect(cards).toHaveCount(5);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
172
tests/specs/scim.spec.ts
Normal file
172
tests/specs/scim.spec.ts
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
import test, { expect, type Page } from '@playwright/test';
|
||||||
|
import { cleanupBackend, cleanupScimServiceProvider } from 'utils/cleanup.util';
|
||||||
|
import { oidcClients, userGroups, users } from '../data';
|
||||||
|
|
||||||
|
async function configureOidcClient(page: Page) {
|
||||||
|
await page.goto(`/settings/admin/oidc-clients/${oidcClients.scim.id}`);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Expand card' }).nth(1).click();
|
||||||
|
|
||||||
|
await page
|
||||||
|
.getByLabel('SCIM Endpoint')
|
||||||
|
.fill(process.env.SCIM_SERVICE_PROVIDER_URL_INTERNAL || 'http://scim.provider/api');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Enable' }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncScimServiceProvider(page: Page) {
|
||||||
|
await page.goto(`/settings/admin/oidc-clients/${oidcClients.scim.id}`);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Sync now' }).click();
|
||||||
|
await page.waitForSelector('[data-type="success"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
test.beforeEach(async () => {
|
||||||
|
await cleanupBackend({ skipLdapSetup: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('SCIM Configuration', () => {
|
||||||
|
test('Enable SCIM for OIDC client', async ({ page }) => {
|
||||||
|
await page.goto(`/settings/admin/oidc-clients/${oidcClients.scim.id}`);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Expand card' }).nth(1).click();
|
||||||
|
|
||||||
|
await page.getByLabel('SCIM Endpoint').fill('http://scim.provider/api');
|
||||||
|
await page.getByLabel('SCIM Token').fill('supersecrettoken');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Enable' }).click();
|
||||||
|
|
||||||
|
await expect(page.locator('[data-type="success"]')).toHaveText('SCIM enabled successfully.');
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
await expect(page.getByLabel('SCIM Endpoint')).toHaveValue('http://scim.provider/api');
|
||||||
|
await expect(page.getByLabel('SCIM Token')).toHaveValue('supersecrettoken');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Update SCIM of OIDC client', async ({ page }) => {
|
||||||
|
await configureOidcClient(page);
|
||||||
|
|
||||||
|
await page.goto(`/settings/admin/oidc-clients/${oidcClients.scim.id}`);
|
||||||
|
|
||||||
|
await page.getByLabel('SCIM Endpoint').fill('http://new.scim.provider/api');
|
||||||
|
await page.getByLabel('SCIM Token').fill('evenmoresecrettoken');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Save' }).nth(1).click();
|
||||||
|
|
||||||
|
await expect(page.locator('[data-type="success"]')).toHaveText(
|
||||||
|
'SCIM configuration updated successfully.'
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
await expect(page.getByLabel('SCIM Endpoint')).toHaveValue('http://new.scim.provider/api');
|
||||||
|
await expect(page.getByLabel('SCIM Token')).toHaveValue('evenmoresecrettoken');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Disable SCIM of OIDC client', async ({ page }) => {
|
||||||
|
await configureOidcClient(page);
|
||||||
|
|
||||||
|
await page.goto(`/settings/admin/oidc-clients/${oidcClients.scim.id}`);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Disable' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Disable' }).nth(1).click();
|
||||||
|
|
||||||
|
await expect(page.locator('[data-type="success"]')).toHaveText('SCIM disabled successfully.');
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
await expect(page.getByRole('button', { name: 'Enable' })).toBeVisible();
|
||||||
|
await expect(page.getByLabel('SCIM Endpoint')).toHaveValue('');
|
||||||
|
await expect(page.getByLabel('SCIM Token')).toHaveValue('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('SCIM Sync', () => {
|
||||||
|
test.skip(
|
||||||
|
!process.env.SCIM_SERVICE_PROVIDER_URL || !process.env.SCIM_SERVICE_PROVIDER_URL_INTERNAL,
|
||||||
|
'Skipping SCIM Sync tests because SCIM_SERVICE_PROVIDER_URL or SCIM_SERVICE_PROVIDER_URL_INTERNAL is not set'
|
||||||
|
);
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await Promise.all([configureOidcClient(page), cleanupScimServiceProvider()]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Sync client', async ({ page }) => {
|
||||||
|
await syncScimServiceProvider(page);
|
||||||
|
|
||||||
|
const scimUsers = await getScimResources('Users');
|
||||||
|
await expect(scimUsers.length).toBe(2);
|
||||||
|
|
||||||
|
const groups = await getScimResources('Groups');
|
||||||
|
await expect(groups.length).toBe(2);
|
||||||
|
|
||||||
|
const timUser = scimUsers.find((u: any) => u.userName === 'tim');
|
||||||
|
await expect(timUser).toBeDefined();
|
||||||
|
await expect(timUser).toMatchObject({
|
||||||
|
externalId: users.tim.id,
|
||||||
|
emails: [
|
||||||
|
{
|
||||||
|
value: users.tim.email,
|
||||||
|
primary: true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
name: {
|
||||||
|
givenName: users.tim.firstname,
|
||||||
|
familyName: users.tim.lastname
|
||||||
|
},
|
||||||
|
displayName: users.tim.displayName,
|
||||||
|
active: true
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Remove allowed group and sync', async ({ page }) => {
|
||||||
|
await syncScimServiceProvider(page);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Expand card' }).first().click();
|
||||||
|
|
||||||
|
await page
|
||||||
|
.getByRole('row', { name: userGroups.developers.name })
|
||||||
|
.getByRole('cell')
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Save' }).nth(1).click();
|
||||||
|
|
||||||
|
await syncScimServiceProvider(page);
|
||||||
|
|
||||||
|
const scimUsers = await getScimResources('Users');
|
||||||
|
await expect(scimUsers.length).toBe(1);
|
||||||
|
await expect(scimUsers.find((u: any) => u.userName === users.tim.username)).toBeDefined();
|
||||||
|
|
||||||
|
const scimGroups = await getScimResources('Groups');
|
||||||
|
await expect(scimGroups.length).toBe(1);
|
||||||
|
await expect(
|
||||||
|
scimGroups.find((g: any) => g.displayName === userGroups.designers.friendlyName)
|
||||||
|
).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Remove group restrictions and sync', async ({ page }) => {
|
||||||
|
await syncScimServiceProvider(page);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Expand card' }).first().click();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Unrestrict' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Unrestrict' }).nth(1).click();
|
||||||
|
|
||||||
|
await syncScimServiceProvider(page);
|
||||||
|
|
||||||
|
const scimUsers = await getScimResources('Users');
|
||||||
|
await expect(scimUsers.length).toBe(3);
|
||||||
|
|
||||||
|
const scimGroups = await getScimResources('Groups');
|
||||||
|
await expect(scimGroups.length).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function getScimResources(resourceType: 'Users' | 'Groups') {
|
||||||
|
const response = await fetch(`${process.env.SCIM_SERVICE_PROVIDER_URL}/${resourceType}`).then(
|
||||||
|
(res) => res.json()
|
||||||
|
);
|
||||||
|
return response['Resources'];
|
||||||
|
}
|
||||||
@@ -19,3 +19,8 @@ export async function cleanupBackend({ skipSeed = false, skipLdapSetup = false }
|
|||||||
throw new Error(`Failed to reset backend: ${response.status} ${response.statusText}`);
|
throw new Error(`Failed to reset backend: ${response.status} ${response.statusText}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function cleanupScimServiceProvider() {
|
||||||
|
if (!process.env.SCIM_SERVICE_PROVIDER_URL) return;
|
||||||
|
await fetch(`${process.env.SCIM_SERVICE_PROVIDER_URL}/reset`, { method: 'POST' });
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user