mirror of
https://github.com/phishingclub/phishingclub.git
synced 2026-07-10 14:18:42 +02:00
@@ -73,6 +73,18 @@ const (
|
||||
ROUTE_V1_COMPANY_ID = "/api/v1/company/:id"
|
||||
ROUTE_V1_COMPANY_ID_EXPORT = "/api/v1/company/:id/export"
|
||||
ROUTE_V1_COMPANY_ID_EXPORT_SHARED = "/api/v1/company/shared/export"
|
||||
ROUTE_V1_COMPANY_SCIM = "/api/v1/company/scim/:companyID"
|
||||
ROUTE_V1_COMPANY_SCIM_TOKEN = "/api/v1/company/scim/:companyID/token"
|
||||
// scim v2 provisioning endpoints (public — authenticated via bearer token)
|
||||
ROUTE_SCIM_V2_SERVICE_PROVIDER_CONFIG = "/api/v1/scim/v2/:companyID/ServiceProviderConfig"
|
||||
ROUTE_SCIM_V2_RESOURCE_TYPES = "/api/v1/scim/v2/:companyID/ResourceTypes"
|
||||
ROUTE_SCIM_V2_SCHEMAS = "/api/v1/scim/v2/:companyID/Schemas"
|
||||
ROUTE_SCIM_V2_SCHEMA_ID = "/api/v1/scim/v2/:companyID/Schemas/:schemaID"
|
||||
ROUTE_SCIM_V2_RESOURCE_TYPE_ID = "/api/v1/scim/v2/:companyID/ResourceTypes/:resourceTypeID"
|
||||
ROUTE_SCIM_V2_USERS = "/api/v1/scim/v2/:companyID/Users"
|
||||
ROUTE_SCIM_V2_USER_ID = "/api/v1/scim/v2/:companyID/Users/:userID"
|
||||
ROUTE_SCIM_V2_GROUPS = "/api/v1/scim/v2/:companyID/Groups"
|
||||
ROUTE_SCIM_V2_GROUP_ID = "/api/v1/scim/v2/:companyID/Groups/:groupID"
|
||||
// option
|
||||
ROUTE_V1_OPTION = "/api/v1/option"
|
||||
ROUTE_V1_OPTION_GET = "/api/v1/option/:key"
|
||||
@@ -240,20 +252,47 @@ func (a *administrationServer) Router() *gin.Engine {
|
||||
return a.router
|
||||
}
|
||||
|
||||
// setupRoutes sets up the routes for the administration app
|
||||
// setupRoutes sets up the routes for the administration app.
|
||||
// ip-protected admin routes are registered under a group that applies the
|
||||
// IPLimiter middleware. scim v2 provisioning routes are registered directly on
|
||||
// the engine so that external identity providers are never blocked by the
|
||||
// admin ip allowlist.
|
||||
func setupRoutes(
|
||||
r *gin.Engine,
|
||||
controllers *Controllers,
|
||||
middleware *Middlewares,
|
||||
) *gin.Engine {
|
||||
// scim v2 provisioning endpoints — no ip allowlist, bearer-token auth only
|
||||
r.
|
||||
GET(ROUTE_SCIM_V2_SERVICE_PROVIDER_CONFIG, controllers.Scim.ServiceProviderConfig).
|
||||
GET(ROUTE_SCIM_V2_RESOURCE_TYPES, controllers.Scim.ResourceTypes).
|
||||
GET(ROUTE_SCIM_V2_SCHEMAS, controllers.Scim.Schemas).
|
||||
GET(ROUTE_SCIM_V2_SCHEMA_ID, controllers.Scim.GetSchema).
|
||||
GET(ROUTE_SCIM_V2_RESOURCE_TYPE_ID, controllers.Scim.GetResourceType).
|
||||
GET(ROUTE_SCIM_V2_USERS, controllers.Scim.ListUsers).
|
||||
POST(ROUTE_SCIM_V2_USERS, controllers.Scim.CreateUser).
|
||||
GET(ROUTE_SCIM_V2_USER_ID, controllers.Scim.GetUser).
|
||||
PUT(ROUTE_SCIM_V2_USER_ID, controllers.Scim.ReplaceUser).
|
||||
PATCH(ROUTE_SCIM_V2_USER_ID, controllers.Scim.PatchUser).
|
||||
DELETE(ROUTE_SCIM_V2_USER_ID, controllers.Scim.DeleteUser).
|
||||
GET(ROUTE_SCIM_V2_GROUPS, controllers.Scim.ListGroups).
|
||||
POST(ROUTE_SCIM_V2_GROUPS, controllers.Scim.CreateGroup).
|
||||
GET(ROUTE_SCIM_V2_GROUP_ID, controllers.Scim.GetGroup).
|
||||
PUT(ROUTE_SCIM_V2_GROUP_ID, controllers.Scim.ReplaceGroup).
|
||||
PATCH(ROUTE_SCIM_V2_GROUP_ID, controllers.Scim.PatchGroup).
|
||||
DELETE(ROUTE_SCIM_V2_GROUP_ID, controllers.Scim.DeleteGroup)
|
||||
|
||||
// all other admin routes are protected by the ip allowlist middleware
|
||||
admin := r.Group("/", middleware.IPLimiter)
|
||||
_ = admin
|
||||
|
||||
if !build.Flags.Production {
|
||||
r.
|
||||
admin.
|
||||
GET("/api/v1/_debug/panic", middleware.SessionHandler, controllers.Log.Panic).
|
||||
GET("/api/v1/_debug/slow", middleware.SessionHandler, controllers.Log.Slow)
|
||||
}
|
||||
|
||||
r.
|
||||
admin.
|
||||
// log
|
||||
GET(ROUTE_V1_LOG, middleware.SessionHandler, controllers.Log.GetLevel).
|
||||
POST(ROUTE_V1_LOG, middleware.SessionHandler, controllers.Log.SetLevel).
|
||||
@@ -310,6 +349,11 @@ func setupRoutes(
|
||||
GET(ROUTE_V1_COMPANY_ID_EXPORT_SHARED, middleware.SessionHandler, controllers.Company.ExportShared).
|
||||
GET(ROUTE_V1_COMPANY_ID, middleware.SessionHandler, controllers.Company.GetByID).
|
||||
DELETE(ROUTE_V1_COMPANY_ID, middleware.SessionHandler, controllers.Company.DeleteByID).
|
||||
// company scim
|
||||
GET(ROUTE_V1_COMPANY_SCIM, middleware.SessionHandler, controllers.CompanyScimConfig.GetByCompanyID).
|
||||
POST(ROUTE_V1_COMPANY_SCIM, middleware.SessionHandler, controllers.CompanyScimConfig.Upsert).
|
||||
DELETE(ROUTE_V1_COMPANY_SCIM, middleware.SessionHandler, controllers.CompanyScimConfig.Delete).
|
||||
POST(ROUTE_V1_COMPANY_SCIM_TOKEN, middleware.SessionHandler, controllers.CompanyScimConfig.RotateToken).
|
||||
// options
|
||||
GET(ROUTE_V1_OPTION_GET, middleware.SessionHandler, controllers.Option.Get).
|
||||
POST(ROUTE_V1_OPTION, middleware.SessionHandler, middleware.SessionHandler, controllers.Option.Update).
|
||||
|
||||
@@ -40,6 +40,8 @@ type Controllers struct {
|
||||
Backup *controller.Backup
|
||||
IPAllowList *controller.IPAllowList
|
||||
OAuthProvider *controller.OAuthProvider
|
||||
CompanyScimConfig *controller.CompanyScimConfig
|
||||
Scim *controller.Scim
|
||||
}
|
||||
|
||||
// NewControllers creates a collection of controllers
|
||||
@@ -196,6 +198,14 @@ func NewControllers(
|
||||
OAuthProviderService: services.OAuthProvider,
|
||||
Config: conf,
|
||||
}
|
||||
companyScimConfig := &controller.CompanyScimConfig{
|
||||
Common: common,
|
||||
CompanyScimConfigService: services.CompanyScimConfig,
|
||||
}
|
||||
scim := &controller.Scim{
|
||||
Common: common,
|
||||
ScimService: services.Scim,
|
||||
}
|
||||
|
||||
return &Controllers{
|
||||
Asset: asset,
|
||||
@@ -229,5 +239,7 @@ func NewControllers(
|
||||
Backup: backup,
|
||||
IPAllowList: ipAllowList,
|
||||
OAuthProvider: oauthProvider,
|
||||
CompanyScimConfig: companyScimConfig,
|
||||
Scim: scim,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ type Repositories struct {
|
||||
OAuthProvider *repository.OAuthProvider
|
||||
OAuthState *repository.OAuthState
|
||||
MicrosoftDeviceCode *repository.MicrosoftDeviceCode
|
||||
CompanyScimConfig *repository.CompanyScimConfig
|
||||
}
|
||||
|
||||
// NewRepositories creates a collection of repositories
|
||||
@@ -63,5 +64,6 @@ func NewRepositories(
|
||||
OAuthProvider: &repository.OAuthProvider{DB: db},
|
||||
OAuthState: &repository.OAuthState{DB: db},
|
||||
MicrosoftDeviceCode: &repository.MicrosoftDeviceCode{DB: db},
|
||||
CompanyScimConfig: &repository.CompanyScimConfig{DB: db},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,8 @@ type Services struct {
|
||||
ProxySessionManager *service.ProxySessionManager
|
||||
OAuthProvider *service.OAuthProvider
|
||||
MicrosoftDeviceCode *service.MicrosoftDeviceCode
|
||||
CompanyScimConfig *service.CompanyScimConfig
|
||||
Scim *service.Scim
|
||||
}
|
||||
|
||||
// NewServices creates a collection of services
|
||||
@@ -274,8 +276,22 @@ func NewServices(
|
||||
|
||||
// inject oauth provider service into api sender
|
||||
apiSender.OAuthProviderService = oauthProvider
|
||||
companyScimConfig := &service.CompanyScimConfig{
|
||||
Common: common,
|
||||
CompanyScimConfigRepository: repositories.CompanyScimConfig,
|
||||
}
|
||||
scim := &service.Scim{
|
||||
Common: common,
|
||||
CompanyScimConfigRepository: repositories.CompanyScimConfig,
|
||||
CompanyScimConfigService: companyScimConfig,
|
||||
RecipientRepository: repositories.Recipient,
|
||||
RecipientGroupRepository: repositories.RecipientGroup,
|
||||
CampaignRepository: repositories.Campaign,
|
||||
}
|
||||
|
||||
return &Services{
|
||||
CompanyScimConfig: companyScimConfig,
|
||||
Scim: scim,
|
||||
Asset: asset,
|
||||
Attachment: attachment,
|
||||
Company: companyService,
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/phishingclub/phishingclub/errs"
|
||||
"github.com/phishingclub/phishingclub/service"
|
||||
)
|
||||
|
||||
// CompanyScimConfig is the SCIM configuration controller
|
||||
type CompanyScimConfig struct {
|
||||
Common
|
||||
CompanyScimConfigService *service.CompanyScimConfig
|
||||
}
|
||||
|
||||
// upsertScimRequest is the request body for the Upsert handler
|
||||
type upsertScimRequest struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// GetByCompanyID returns the SCIM configuration for the given company
|
||||
func (c *CompanyScimConfig) GetByCompanyID(g *gin.Context) {
|
||||
session, _, ok := c.handleSession(g)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// parse companyID from url param
|
||||
companyID, err := uuid.Parse(g.Param("companyID"))
|
||||
if err != nil {
|
||||
c.Logger.Debugw("failed to parse companyID param", "error", err)
|
||||
c.Response.BadRequestMessage(g, errs.MsgFailedToParseUUID)
|
||||
return
|
||||
}
|
||||
// get
|
||||
config, err := c.CompanyScimConfigService.GetByCompanyID(
|
||||
g.Request.Context(),
|
||||
session,
|
||||
&companyID,
|
||||
)
|
||||
// handle response
|
||||
if ok := c.handleErrors(g, err); !ok {
|
||||
return
|
||||
}
|
||||
c.Response.OK(g, config)
|
||||
}
|
||||
|
||||
// Upsert creates or updates the SCIM configuration for the given company
|
||||
func (c *CompanyScimConfig) Upsert(g *gin.Context) {
|
||||
session, _, ok := c.handleSession(g)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// parse companyID from url param
|
||||
companyID, err := uuid.Parse(g.Param("companyID"))
|
||||
if err != nil {
|
||||
c.Logger.Debugw("failed to parse companyID param", "error", err)
|
||||
c.Response.BadRequestMessage(g, errs.MsgFailedToParseUUID)
|
||||
return
|
||||
}
|
||||
// parse request body
|
||||
var req upsertScimRequest
|
||||
if ok := c.handleParseRequest(g, &req); !ok {
|
||||
return
|
||||
}
|
||||
// upsert
|
||||
result, err := c.CompanyScimConfigService.Upsert(
|
||||
g.Request.Context(),
|
||||
session,
|
||||
&companyID,
|
||||
req.Enabled,
|
||||
)
|
||||
// handle response
|
||||
if ok := c.handleErrors(g, err); !ok {
|
||||
return
|
||||
}
|
||||
c.Response.OK(g, result)
|
||||
}
|
||||
|
||||
// RotateToken generates a new bearer token for the given company's SCIM config.
|
||||
// the plain token is returned once and must be stored by the caller.
|
||||
func (c *CompanyScimConfig) RotateToken(g *gin.Context) {
|
||||
session, _, ok := c.handleSession(g)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// parse companyID from url param
|
||||
companyID, err := uuid.Parse(g.Param("companyID"))
|
||||
if err != nil {
|
||||
c.Logger.Debugw("failed to parse companyID param", "error", err)
|
||||
c.Response.BadRequestMessage(g, errs.MsgFailedToParseUUID)
|
||||
return
|
||||
}
|
||||
// rotate
|
||||
result, err := c.CompanyScimConfigService.RotateToken(
|
||||
g.Request.Context(),
|
||||
session,
|
||||
&companyID,
|
||||
)
|
||||
// handle response
|
||||
if ok := c.handleErrors(g, err); !ok {
|
||||
return
|
||||
}
|
||||
c.Response.OK(g, result)
|
||||
}
|
||||
|
||||
// Delete removes the SCIM configuration for the given company
|
||||
func (c *CompanyScimConfig) Delete(g *gin.Context) {
|
||||
session, _, ok := c.handleSession(g)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// parse companyID from url param
|
||||
companyID, err := uuid.Parse(g.Param("companyID"))
|
||||
if err != nil {
|
||||
c.Logger.Debugw("failed to parse companyID param", "error", err)
|
||||
c.Response.BadRequestMessage(g, errs.MsgFailedToParseUUID)
|
||||
return
|
||||
}
|
||||
// delete
|
||||
err = c.CompanyScimConfigService.DeleteByCompanyID(
|
||||
g.Request.Context(),
|
||||
session,
|
||||
&companyID,
|
||||
)
|
||||
// handle response
|
||||
if ok := c.handleErrors(g, err); !ok {
|
||||
return
|
||||
}
|
||||
c.Response.OK(g, gin.H{})
|
||||
}
|
||||
@@ -0,0 +1,677 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-errors/errors"
|
||||
"github.com/google/uuid"
|
||||
"github.com/phishingclub/phishingclub/errs"
|
||||
"github.com/phishingclub/phishingclub/service"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
scimContentType = "application/scim+json"
|
||||
)
|
||||
|
||||
// Scim is the SCIM v2 protocol controller.
|
||||
// all handlers authenticate via bearer token before dispatching to the service layer.
|
||||
type Scim struct {
|
||||
Common
|
||||
ScimService *service.Scim
|
||||
}
|
||||
|
||||
// scimError writes a SCIM-compliant error response
|
||||
func scimError(g *gin.Context, status int, detail string, scimType ...string) {
|
||||
body := service.ScimError{
|
||||
Schemas: []string{"urn:ietf:params:scim:api:messages:2.0:Error"},
|
||||
Status: status,
|
||||
Detail: detail,
|
||||
}
|
||||
if len(scimType) > 0 {
|
||||
body.ScimType = scimType[0]
|
||||
}
|
||||
g.Header("Content-Type", scimContentType)
|
||||
g.JSON(status, body)
|
||||
g.Abort()
|
||||
}
|
||||
|
||||
// scimOK writes a SCIM-compliant success response with Content-Type application/scim+json
|
||||
func scimOK(g *gin.Context, status int, body any) {
|
||||
g.Header("Content-Type", scimContentType)
|
||||
g.JSON(status, body)
|
||||
}
|
||||
|
||||
// authenticate validates the Authorization: Bearer <token> header and returns
|
||||
// the verified SCIM config for the company. on failure it writes the error
|
||||
// response itself and returns nil, false.
|
||||
func (c *Scim) authenticate(g *gin.Context, companyID *uuid.UUID) (*service.ScimConfigResult, bool) {
|
||||
authHeader := g.GetHeader("Authorization")
|
||||
const bearerPrefix = "Bearer "
|
||||
if !strings.HasPrefix(authHeader, bearerPrefix) {
|
||||
scimError(g, http.StatusUnauthorized, "missing or malformed Authorization header")
|
||||
return nil, false
|
||||
}
|
||||
token := strings.TrimPrefix(authHeader, bearerPrefix)
|
||||
if token == "" {
|
||||
scimError(g, http.StatusUnauthorized, "empty bearer token")
|
||||
return nil, false
|
||||
}
|
||||
|
||||
config, authed, err := c.ScimService.VerifyAndLoadConfig(g.Request.Context(), companyID, token)
|
||||
if err != nil {
|
||||
if isNotFound(err) {
|
||||
scimError(g, http.StatusUnauthorized, "no SCIM configuration found for this company")
|
||||
return nil, false
|
||||
}
|
||||
c.Logger.Errorw("scim auth: error verifying token", "error", err)
|
||||
scimError(g, http.StatusInternalServerError, "internal error during authentication")
|
||||
return nil, false
|
||||
}
|
||||
if !authed {
|
||||
scimError(g, http.StatusUnauthorized, "invalid bearer token or SCIM provisioning is disabled")
|
||||
return nil, false
|
||||
}
|
||||
return &service.ScimConfigResult{Config: config, CompanyID: companyID}, true
|
||||
}
|
||||
|
||||
// parseCompanyID extracts and parses the :companyID path parameter.
|
||||
// writes the error response and returns false on failure.
|
||||
func (c *Scim) parseCompanyID(g *gin.Context) (*uuid.UUID, bool) {
|
||||
id, err := uuid.Parse(g.Param("companyID"))
|
||||
if err != nil {
|
||||
scimError(g, http.StatusBadRequest, "invalid companyID in URL path")
|
||||
return nil, false
|
||||
}
|
||||
return &id, true
|
||||
}
|
||||
|
||||
// parseUserID extracts and parses the :userID path parameter.
|
||||
// an unparseable id means the resource cannot exist, so 404 is correct per rfc 7644.
|
||||
func (c *Scim) parseUserID(g *gin.Context) (*uuid.UUID, bool) {
|
||||
id, err := uuid.Parse(g.Param("userID"))
|
||||
if err != nil {
|
||||
scimError(g, http.StatusNotFound, "user not found")
|
||||
return nil, false
|
||||
}
|
||||
return &id, true
|
||||
}
|
||||
|
||||
// parseGroupID extracts and parses the :groupID path parameter.
|
||||
// an unparseable id means the resource cannot exist, so 404 is correct per rfc 7644.
|
||||
func (c *Scim) parseGroupID(g *gin.Context) (*uuid.UUID, bool) {
|
||||
id, err := uuid.Parse(g.Param("groupID"))
|
||||
if err != nil {
|
||||
scimError(g, http.StatusNotFound, "group not found")
|
||||
return nil, false
|
||||
}
|
||||
return &id, true
|
||||
}
|
||||
|
||||
// scimBaseURL builds the base URL for SCIM resource locations from the request
|
||||
func scimBaseURL(g *gin.Context, companyID *uuid.UUID) string {
|
||||
scheme := "https"
|
||||
if g.Request.TLS == nil {
|
||||
scheme = "http"
|
||||
}
|
||||
return scheme + "://" + g.Request.Host + "/api/v1/scim/v2/" + companyID.String()
|
||||
}
|
||||
|
||||
// isNotFound returns true when the error wraps gorm.ErrRecordNotFound
|
||||
func isNotFound(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(err.Error(), gorm.ErrRecordNotFound.Error())
|
||||
}
|
||||
|
||||
// ── ServiceProviderConfig ─────────────────────────────────────────────────────
|
||||
|
||||
// ServiceProviderConfig handles GET /scim/v2/:companyID/ServiceProviderConfig
|
||||
func (c *Scim) ServiceProviderConfig(g *gin.Context) {
|
||||
companyID, ok := c.parseCompanyID(g)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if _, ok := c.authenticate(g, companyID); !ok {
|
||||
return
|
||||
}
|
||||
base := scimBaseURL(g, companyID)
|
||||
scimOK(g, http.StatusOK, c.ScimService.ServiceProviderConfig(base))
|
||||
}
|
||||
|
||||
// ── Schemas ───────────────────────────────────────────────────────────────────
|
||||
|
||||
// Schemas handles GET /api/v1/scim/v2/:companyID/Schemas
|
||||
func (c *Scim) Schemas(g *gin.Context) {
|
||||
companyID, ok := c.parseCompanyID(g)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if _, ok := c.authenticate(g, companyID); !ok {
|
||||
return
|
||||
}
|
||||
base := scimBaseURL(g, companyID)
|
||||
schemas := c.ScimService.Schemas(base)
|
||||
scimOK(g, http.StatusOK, gin.H{
|
||||
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:ListResponse"},
|
||||
"totalResults": len(schemas),
|
||||
"startIndex": 1,
|
||||
"itemsPerPage": len(schemas),
|
||||
"Resources": schemas,
|
||||
})
|
||||
}
|
||||
|
||||
// GetSchema handles GET /api/v1/scim/v2/:companyID/Schemas/:schemaID
|
||||
func (c *Scim) GetSchema(g *gin.Context) {
|
||||
companyID, ok := c.parseCompanyID(g)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if _, ok := c.authenticate(g, companyID); !ok {
|
||||
return
|
||||
}
|
||||
schemaID := g.Param("schemaID")
|
||||
base := scimBaseURL(g, companyID)
|
||||
schema, found := c.ScimService.GetSchemaByID(base, schemaID)
|
||||
if !found {
|
||||
scimError(g, http.StatusNotFound, "schema not found")
|
||||
return
|
||||
}
|
||||
scimOK(g, http.StatusOK, schema)
|
||||
}
|
||||
|
||||
// ── ResourceTypes ─────────────────────────────────────────────────────────────
|
||||
|
||||
// ResourceTypes handles GET /scim/v2/:companyID/ResourceTypes
|
||||
func (c *Scim) ResourceTypes(g *gin.Context) {
|
||||
companyID, ok := c.parseCompanyID(g)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if _, ok := c.authenticate(g, companyID); !ok {
|
||||
return
|
||||
}
|
||||
base := scimBaseURL(g, companyID)
|
||||
types := c.ScimService.ResourceTypes(base)
|
||||
scimOK(g, http.StatusOK, gin.H{
|
||||
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:ListResponse"},
|
||||
"totalResults": len(types),
|
||||
"startIndex": 1,
|
||||
"itemsPerPage": len(types),
|
||||
"Resources": types,
|
||||
})
|
||||
}
|
||||
|
||||
// GetResourceType handles GET /scim/v2/:companyID/ResourceTypes/:resourceTypeID
|
||||
func (c *Scim) GetResourceType(g *gin.Context) {
|
||||
companyID, ok := c.parseCompanyID(g)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if _, ok := c.authenticate(g, companyID); !ok {
|
||||
return
|
||||
}
|
||||
resourceTypeID := g.Param("resourceTypeID")
|
||||
base := scimBaseURL(g, companyID)
|
||||
rt, found := c.ScimService.GetResourceTypeByID(base, resourceTypeID)
|
||||
if !found {
|
||||
scimError(g, http.StatusNotFound, "resource type not found")
|
||||
return
|
||||
}
|
||||
scimOK(g, http.StatusOK, rt)
|
||||
}
|
||||
|
||||
// ── Groups ────────────────────────────────────────────────────────────────────
|
||||
|
||||
// ListGroups handles GET /api/v1/scim/v2/:companyID/Groups
|
||||
func (c *Scim) ListGroups(g *gin.Context) {
|
||||
companyID, ok := c.parseCompanyID(g)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result, ok := c.authenticate(g, companyID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
base := scimBaseURL(g, companyID)
|
||||
startIndex := parseIntQuery(g, "startIndex", 1)
|
||||
count := parseIntQuery(g, "count", 0)
|
||||
filter := g.Query("filter")
|
||||
excludedAttributes := g.Query("excludedAttributes")
|
||||
|
||||
list, err := c.ScimService.ListGroupsRaw(g.Request.Context(), companyID, result.Config, base, startIndex, count, filter, excludedAttributes)
|
||||
if err != nil {
|
||||
c.Logger.Errorw("scim list groups: error", "error", err)
|
||||
scimError(g, http.StatusInternalServerError, "failed to list groups")
|
||||
return
|
||||
}
|
||||
scimOK(g, http.StatusOK, list)
|
||||
}
|
||||
|
||||
// GetGroup handles GET /api/v1/scim/v2/:companyID/Groups/:groupID
|
||||
func (c *Scim) GetGroup(g *gin.Context) {
|
||||
companyID, ok := c.parseCompanyID(g)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result, ok := c.authenticate(g, companyID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
groupID, ok := c.parseGroupID(g)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
base := scimBaseURL(g, companyID)
|
||||
|
||||
group, err := c.ScimService.GetGroup(g.Request.Context(), companyID, result.Config, groupID, base)
|
||||
if err != nil {
|
||||
if isNotFound(err) {
|
||||
scimError(g, http.StatusNotFound, "group not found")
|
||||
return
|
||||
}
|
||||
c.Logger.Errorw("scim get group: error", "error", err)
|
||||
scimError(g, http.StatusInternalServerError, "failed to get group")
|
||||
return
|
||||
}
|
||||
scimOK(g, http.StatusOK, group)
|
||||
}
|
||||
|
||||
// CreateGroup handles POST /api/v1/scim/v2/:companyID/Groups
|
||||
func (c *Scim) CreateGroup(g *gin.Context) {
|
||||
companyID, ok := c.parseCompanyID(g)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result, ok := c.authenticate(g, companyID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var req service.ScimGroup
|
||||
if err := g.ShouldBindJSON(&req); err != nil {
|
||||
scimError(g, http.StatusBadRequest, "invalid request body: "+err.Error(), "invalidSyntax")
|
||||
return
|
||||
}
|
||||
|
||||
base := scimBaseURL(g, companyID)
|
||||
created, err := c.ScimService.CreateGroup(g.Request.Context(), companyID, result.Config, &req, base)
|
||||
if err != nil {
|
||||
if isSyntaxError(err) {
|
||||
scimError(g, http.StatusBadRequest, err.Error(), "invalidSyntax")
|
||||
return
|
||||
}
|
||||
if isConflictError(err) {
|
||||
scimError(g, http.StatusConflict, err.Error(), "uniqueness")
|
||||
return
|
||||
}
|
||||
if isValidationError(err) {
|
||||
scimError(g, http.StatusBadRequest, err.Error(), "invalidValue")
|
||||
return
|
||||
}
|
||||
c.Logger.Errorw("scim create group: error", "error", err)
|
||||
scimError(g, http.StatusInternalServerError, "failed to create group")
|
||||
return
|
||||
}
|
||||
go c.ScimService.UpdateLastSync(context.Background(), result.Config)
|
||||
scimOK(g, http.StatusCreated, created)
|
||||
}
|
||||
|
||||
// ReplaceGroup handles PUT /api/v1/scim/v2/:companyID/Groups/:groupID
|
||||
func (c *Scim) ReplaceGroup(g *gin.Context) {
|
||||
companyID, ok := c.parseCompanyID(g)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result, ok := c.authenticate(g, companyID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
groupID, ok := c.parseGroupID(g)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var req service.ScimGroup
|
||||
if err := g.ShouldBindJSON(&req); err != nil {
|
||||
scimError(g, http.StatusBadRequest, "invalid request body: "+err.Error(), "invalidValue")
|
||||
return
|
||||
}
|
||||
|
||||
base := scimBaseURL(g, companyID)
|
||||
updated, err := c.ScimService.ReplaceGroup(g.Request.Context(), companyID, result.Config, groupID, &req, base)
|
||||
if err != nil {
|
||||
if isNotFound(err) {
|
||||
scimError(g, http.StatusNotFound, "group not found")
|
||||
return
|
||||
}
|
||||
if isValidationError(err) {
|
||||
scimError(g, http.StatusBadRequest, err.Error(), "invalidValue")
|
||||
return
|
||||
}
|
||||
c.Logger.Errorw("scim replace group: error", "error", err)
|
||||
scimError(g, http.StatusInternalServerError, "failed to replace group")
|
||||
return
|
||||
}
|
||||
go c.ScimService.UpdateLastSync(context.Background(), result.Config)
|
||||
scimOK(g, http.StatusOK, updated)
|
||||
}
|
||||
|
||||
// PatchGroup handles PATCH /api/v1/scim/v2/:companyID/Groups/:groupID
|
||||
func (c *Scim) PatchGroup(g *gin.Context) {
|
||||
companyID, ok := c.parseCompanyID(g)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result, ok := c.authenticate(g, companyID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
groupID, ok := c.parseGroupID(g)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var req service.ScimGroupPatchOp
|
||||
if err := g.ShouldBindJSON(&req); err != nil {
|
||||
scimError(g, http.StatusBadRequest, "invalid patch body: "+err.Error(), "invalidValue")
|
||||
return
|
||||
}
|
||||
|
||||
base := scimBaseURL(g, companyID)
|
||||
updated, err := c.ScimService.PatchGroup(g.Request.Context(), companyID, result.Config, groupID, &req, base)
|
||||
if err != nil {
|
||||
if isNotFound(err) {
|
||||
scimError(g, http.StatusNotFound, "group not found")
|
||||
return
|
||||
}
|
||||
if isValidationError(err) {
|
||||
scimError(g, http.StatusBadRequest, err.Error(), "invalidValue")
|
||||
return
|
||||
}
|
||||
c.Logger.Errorw("scim patch group: error", "error", err)
|
||||
scimError(g, http.StatusInternalServerError, "failed to patch group")
|
||||
return
|
||||
}
|
||||
go c.ScimService.UpdateLastSync(context.Background(), result.Config)
|
||||
scimOK(g, http.StatusOK, updated)
|
||||
}
|
||||
|
||||
// DeleteGroup handles DELETE /api/v1/scim/v2/:companyID/Groups/:groupID
|
||||
func (c *Scim) DeleteGroup(g *gin.Context) {
|
||||
companyID, ok := c.parseCompanyID(g)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result, ok := c.authenticate(g, companyID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
groupID, ok := c.parseGroupID(g)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
err := c.ScimService.DeleteGroup(g.Request.Context(), companyID, result.Config, groupID)
|
||||
if err != nil {
|
||||
if isNotFound(err) {
|
||||
scimError(g, http.StatusNotFound, "group not found")
|
||||
return
|
||||
}
|
||||
c.Logger.Errorw("scim delete group: error", "error", err)
|
||||
scimError(g, http.StatusInternalServerError, "failed to delete group")
|
||||
return
|
||||
}
|
||||
go c.ScimService.UpdateLastSync(context.Background(), result.Config)
|
||||
// rfc 7644 §3.6 — successful DELETE returns 204 No Content
|
||||
g.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ── Users ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
// ListUsers handles GET /scim/v2/:companyID/Users
|
||||
func (c *Scim) ListUsers(g *gin.Context) {
|
||||
companyID, ok := c.parseCompanyID(g)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result, ok := c.authenticate(g, companyID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
base := scimBaseURL(g, companyID)
|
||||
filter := g.Query("filter")
|
||||
startIndex := parseIntQuery(g, "startIndex", 1)
|
||||
count := parseIntQuery(g, "count", 0)
|
||||
sortBy := g.Query("sortBy")
|
||||
sortOrder := g.Query("sortOrder")
|
||||
|
||||
list, err := c.ScimService.ListUsers(g.Request.Context(), companyID, result.Config, base, filter, startIndex, count, sortBy, sortOrder)
|
||||
if err != nil {
|
||||
c.Logger.Errorw("scim list users: error", "error", err)
|
||||
scimError(g, http.StatusInternalServerError, "failed to list users")
|
||||
return
|
||||
}
|
||||
go c.ScimService.UpdateLastSync(context.Background(), result.Config)
|
||||
scimOK(g, http.StatusOK, list)
|
||||
}
|
||||
|
||||
// GetUser handles GET /scim/v2/:companyID/Users/:userID
|
||||
func (c *Scim) GetUser(g *gin.Context) {
|
||||
companyID, ok := c.parseCompanyID(g)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result, ok := c.authenticate(g, companyID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
userID, ok := c.parseUserID(g)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
base := scimBaseURL(g, companyID)
|
||||
|
||||
user, err := c.ScimService.GetUser(g.Request.Context(), companyID, result.Config, userID, base)
|
||||
if err != nil {
|
||||
if isNotFound(err) {
|
||||
scimError(g, http.StatusNotFound, "user not found")
|
||||
return
|
||||
}
|
||||
c.Logger.Errorw("scim get user: error", "error", err)
|
||||
scimError(g, http.StatusInternalServerError, "failed to get user")
|
||||
return
|
||||
}
|
||||
scimOK(g, http.StatusOK, user)
|
||||
}
|
||||
|
||||
// CreateUser handles POST /scim/v2/:companyID/Users
|
||||
func (c *Scim) CreateUser(g *gin.Context) {
|
||||
companyID, ok := c.parseCompanyID(g)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result, ok := c.authenticate(g, companyID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var req service.ScimUser
|
||||
if err := g.ShouldBindJSON(&req); err != nil {
|
||||
scimError(g, http.StatusBadRequest, "invalid request body: "+err.Error(), "invalidValue")
|
||||
return
|
||||
}
|
||||
|
||||
base := scimBaseURL(g, companyID)
|
||||
created, err := c.ScimService.CreateUser(g.Request.Context(), companyID, result.Config, &req, base)
|
||||
if err != nil {
|
||||
if isConflictError(err) {
|
||||
scimError(g, http.StatusConflict, err.Error(), "uniqueness")
|
||||
return
|
||||
}
|
||||
if isValidationError(err) {
|
||||
scimError(g, http.StatusBadRequest, err.Error(), "invalidValue")
|
||||
return
|
||||
}
|
||||
c.Logger.Errorw("scim create user: error", "error", err)
|
||||
scimError(g, http.StatusInternalServerError, "failed to create user")
|
||||
return
|
||||
}
|
||||
go c.ScimService.UpdateLastSync(context.Background(), result.Config)
|
||||
scimOK(g, http.StatusCreated, created)
|
||||
}
|
||||
|
||||
// ReplaceUser handles PUT /scim/v2/:companyID/Users/:userID
|
||||
func (c *Scim) ReplaceUser(g *gin.Context) {
|
||||
companyID, ok := c.parseCompanyID(g)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result, ok := c.authenticate(g, companyID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
userID, ok := c.parseUserID(g)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var req service.ScimUser
|
||||
if err := g.ShouldBindJSON(&req); err != nil {
|
||||
scimError(g, http.StatusBadRequest, "invalid request body: "+err.Error(), "invalidValue")
|
||||
return
|
||||
}
|
||||
|
||||
base := scimBaseURL(g, companyID)
|
||||
updated, err := c.ScimService.ReplaceUser(g.Request.Context(), companyID, result.Config, userID, &req, base)
|
||||
if err != nil {
|
||||
if isNotFound(err) {
|
||||
scimError(g, http.StatusNotFound, "user not found")
|
||||
return
|
||||
}
|
||||
if isValidationError(err) {
|
||||
scimError(g, http.StatusBadRequest, err.Error(), "invalidValue")
|
||||
return
|
||||
}
|
||||
c.Logger.Errorw("scim replace user: error", "error", err)
|
||||
scimError(g, http.StatusInternalServerError, "failed to replace user")
|
||||
return
|
||||
}
|
||||
go c.ScimService.UpdateLastSync(context.Background(), result.Config)
|
||||
scimOK(g, http.StatusOK, updated)
|
||||
}
|
||||
|
||||
// PatchUser handles PATCH /scim/v2/:companyID/Users/:userID
|
||||
func (c *Scim) PatchUser(g *gin.Context) {
|
||||
companyID, ok := c.parseCompanyID(g)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result, ok := c.authenticate(g, companyID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
userID, ok := c.parseUserID(g)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var req service.ScimPatchOp
|
||||
if err := g.ShouldBindJSON(&req); err != nil {
|
||||
scimError(g, http.StatusBadRequest, "invalid patch body: "+err.Error(), "invalidValue")
|
||||
return
|
||||
}
|
||||
|
||||
base := scimBaseURL(g, companyID)
|
||||
updated, err := c.ScimService.PatchUser(g.Request.Context(), companyID, result.Config, userID, &req, base)
|
||||
if err != nil {
|
||||
if isNotFound(err) {
|
||||
scimError(g, http.StatusNotFound, "user not found")
|
||||
return
|
||||
}
|
||||
if isValidationError(err) {
|
||||
scimError(g, http.StatusBadRequest, err.Error(), "invalidValue")
|
||||
return
|
||||
}
|
||||
c.Logger.Errorw("scim patch user: error", "error", err)
|
||||
scimError(g, http.StatusInternalServerError, "failed to patch user")
|
||||
return
|
||||
}
|
||||
go c.ScimService.UpdateLastSync(context.Background(), result.Config)
|
||||
scimOK(g, http.StatusOK, updated)
|
||||
}
|
||||
|
||||
// DeleteUser handles DELETE /scim/v2/:companyID/Users/:userID
|
||||
func (c *Scim) DeleteUser(g *gin.Context) {
|
||||
companyID, ok := c.parseCompanyID(g)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result, ok := c.authenticate(g, companyID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
userID, ok := c.parseUserID(g)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
err := c.ScimService.DeprovisionUser(g.Request.Context(), companyID, result.Config, userID)
|
||||
if err != nil {
|
||||
if isNotFound(err) {
|
||||
scimError(g, http.StatusNotFound, "user not found")
|
||||
return
|
||||
}
|
||||
c.Logger.Errorw("scim delete user: error", "error", err)
|
||||
scimError(g, http.StatusInternalServerError, "failed to deprovision user")
|
||||
return
|
||||
}
|
||||
go c.ScimService.UpdateLastSync(context.Background(), result.Config)
|
||||
// RFC 7644 §3.6 — successful DELETE returns 204 No Content
|
||||
g.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// isValidationError returns true when the error wraps an errs.ValidationError
|
||||
func isValidationError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
var target errs.ValidationError
|
||||
return errors.As(err, &target)
|
||||
}
|
||||
|
||||
// isSyntaxError returns true when the error wraps an errs.SyntaxError
|
||||
func isSyntaxError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
var target errs.SyntaxError
|
||||
return errors.As(err, &target)
|
||||
}
|
||||
|
||||
// isConflictError returns true when the error wraps an errs.ConflictError
|
||||
func isConflictError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
var target errs.ConflictError
|
||||
return errors.As(err, &target)
|
||||
}
|
||||
|
||||
// parseIntQuery parses a query param as an int, returning defaultVal if absent or invalid.
|
||||
func parseIntQuery(g *gin.Context, key string, defaultVal int) int {
|
||||
raw := g.Query(key)
|
||||
if raw == "" {
|
||||
return defaultVal
|
||||
}
|
||||
v, err := strconv.Atoi(raw)
|
||||
if err != nil || v < 0 {
|
||||
return defaultVal
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
COMPANY_SCIM_CONFIG_TABLE = "company_scim_configs"
|
||||
)
|
||||
|
||||
// CompanyScimConfig holds the SCIM configuration for a company
|
||||
type CompanyScimConfig struct {
|
||||
ID *uuid.UUID `gorm:"primary_key;not null;unique;type:uuid"`
|
||||
CreatedAt *time.Time `gorm:"not null;index;"`
|
||||
UpdatedAt *time.Time `gorm:"not null;index;"`
|
||||
CompanyID *uuid.UUID `gorm:"not null;unique;type:uuid;index;"` // one-to-one with company
|
||||
TokenHash string `gorm:"not null;"` // bcrypt hash of the bearer token
|
||||
TokenPrefix string `gorm:"not null;"` // first 8 chars of token for identification
|
||||
Enabled bool `gorm:"not null;default:true"`
|
||||
LastSyncAt *time.Time // nullable: last time SCIM pushed an update
|
||||
|
||||
Company *Company `gorm:"foreignKey:CompanyID"`
|
||||
}
|
||||
|
||||
func (e *CompanyScimConfig) Migrate(db *gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (CompanyScimConfig) TableName() string {
|
||||
return COMPANY_SCIM_CONFIG_TABLE
|
||||
}
|
||||
@@ -21,13 +21,14 @@ type Recipient struct {
|
||||
Phone *string `gorm:";index"`
|
||||
ExtraIdentifier *string `gorm:";index"`
|
||||
|
||||
FirstName string `gorm:";"`
|
||||
LastName string `gorm:";"`
|
||||
Position string `gorm:";"`
|
||||
Department string `gorm:";"`
|
||||
City string `gorm:";"`
|
||||
Country string `gorm:";"`
|
||||
Misc string `gorm:";"`
|
||||
FirstName string `gorm:";"`
|
||||
LastName string `gorm:";"`
|
||||
Position string `gorm:";"`
|
||||
Department string `gorm:";"`
|
||||
City string `gorm:";"`
|
||||
Country string `gorm:";"`
|
||||
Misc string `gorm:";"`
|
||||
ScimUserName string `gorm:";"`
|
||||
|
||||
// can belong to
|
||||
CompanyID *uuid.UUID `gorm:"type:uuid;index;"`
|
||||
|
||||
@@ -94,6 +94,40 @@ func (e ValidationError) Error() string {
|
||||
return e.Err.Error()
|
||||
}
|
||||
|
||||
// SyntaxError is returned when a request is structurally invalid (scim invalidSyntax)
|
||||
type SyntaxError struct {
|
||||
Err error
|
||||
}
|
||||
|
||||
// NewSyntaxError creates a new syntax error
|
||||
func NewSyntaxError(err error) error {
|
||||
return SyntaxError{
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
|
||||
// Error returns the syntax error
|
||||
func (e SyntaxError) Error() string {
|
||||
return e.Err.Error()
|
||||
}
|
||||
|
||||
// ConflictError is returned when a resource already exists (scim uniqueness conflict)
|
||||
type ConflictError struct {
|
||||
Err error
|
||||
}
|
||||
|
||||
// NewConflictError creates a new conflict error
|
||||
func NewConflictError(err error) error {
|
||||
return ConflictError{
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
|
||||
// Error returns the conflict error
|
||||
func (e ConflictError) Error() string {
|
||||
return e.Err.Error()
|
||||
}
|
||||
|
||||
// CustomError is a custom error
|
||||
type CustomError struct {
|
||||
Err error
|
||||
|
||||
+1
-1
@@ -321,7 +321,7 @@ func main() {
|
||||
"trusted_ip_header", conf.IPSecurity.TrustedIPHeader,
|
||||
)
|
||||
}
|
||||
adminRouter.Use(middlewares.IPLimiter)
|
||||
|
||||
adminServer := app.NewAdministrationServer(
|
||||
adminRouter,
|
||||
controllers,
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/oapi-codegen/nullable"
|
||||
)
|
||||
|
||||
// CompanyScimConfig is the SCIM configuration for a company
|
||||
type CompanyScimConfig struct {
|
||||
ID nullable.Nullable[uuid.UUID] `json:"id"`
|
||||
CreatedAt *time.Time `json:"createdAt"`
|
||||
UpdatedAt *time.Time `json:"updatedAt"`
|
||||
CompanyID nullable.Nullable[uuid.UUID] `json:"companyID"`
|
||||
TokenPrefix nullable.Nullable[string] `json:"tokenPrefix"` // shown to user for identification
|
||||
Enabled bool `json:"enabled"`
|
||||
LastSyncAt *time.Time `json:"lastSyncAt"`
|
||||
// Token is only set when a new token has just been generated (never persisted directly)
|
||||
Token string `json:"token,omitempty"`
|
||||
}
|
||||
|
||||
// Validate checks if the config is valid
|
||||
func (c *CompanyScimConfig) Validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ToDBMap converts the fields for persistence
|
||||
func (c *CompanyScimConfig) ToDBMap() map[string]any {
|
||||
m := map[string]any{}
|
||||
m["enabled"] = c.Enabled
|
||||
return m
|
||||
}
|
||||
@@ -24,6 +24,7 @@ type Recipient struct {
|
||||
City nullable.Nullable[vo.OptionalString127] `json:"city"`
|
||||
Country nullable.Nullable[vo.OptionalString127] `json:"country"`
|
||||
Misc nullable.Nullable[vo.OptionalString127] `json:"misc"`
|
||||
ScimUserName nullable.Nullable[vo.OptionalString127] `json:"scimUserName"`
|
||||
CompanyID nullable.Nullable[uuid.UUID] `json:"companyID"`
|
||||
|
||||
Company *Company `json:"-"`
|
||||
@@ -178,6 +179,12 @@ func (r *Recipient) ToDBMap() map[string]any {
|
||||
m["misc"] = misc.String()
|
||||
}
|
||||
}
|
||||
if r.ScimUserName.IsSpecified() {
|
||||
m["scim_user_name"] = nil
|
||||
if scimUserName, err := r.ScimUserName.Get(); err == nil {
|
||||
m["scim_user_name"] = scimUserName.String()
|
||||
}
|
||||
}
|
||||
if r.CompanyID.IsSpecified() {
|
||||
if r.CompanyID.IsNull() {
|
||||
m["company_id"] = nil
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/oapi-codegen/nullable"
|
||||
"github.com/phishingclub/phishingclub/database"
|
||||
"github.com/phishingclub/phishingclub/errs"
|
||||
"github.com/phishingclub/phishingclub/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// CompanyScimConfig is the repository for company SCIM configuration
|
||||
type CompanyScimConfig struct {
|
||||
DB *gorm.DB
|
||||
}
|
||||
|
||||
// Insert inserts a new company SCIM config row with a pre-computed bcrypt token hash
|
||||
func (r *CompanyScimConfig) Insert(
|
||||
ctx context.Context,
|
||||
config *model.CompanyScimConfig,
|
||||
tokenHash string,
|
||||
) (*uuid.UUID, error) {
|
||||
id := uuid.New()
|
||||
row := map[string]any{}
|
||||
row["id"] = id
|
||||
AddTimestamps(row)
|
||||
|
||||
row["token_hash"] = tokenHash
|
||||
|
||||
if companyID, err := config.CompanyID.Get(); err == nil {
|
||||
row["company_id"] = companyID.String()
|
||||
} else {
|
||||
return nil, fmt.Errorf("company_id is required")
|
||||
}
|
||||
|
||||
if tokenPrefix, err := config.TokenPrefix.Get(); err == nil {
|
||||
row["token_prefix"] = tokenPrefix
|
||||
} else {
|
||||
row["token_prefix"] = ""
|
||||
}
|
||||
|
||||
row["enabled"] = config.Enabled
|
||||
|
||||
res := r.DB.
|
||||
Model(&database.CompanyScimConfig{}).
|
||||
Create(row)
|
||||
|
||||
if res.Error != nil {
|
||||
return nil, errs.Wrap(res.Error)
|
||||
}
|
||||
return &id, nil
|
||||
}
|
||||
|
||||
// GetByCompanyID fetches the SCIM config for a given company
|
||||
func (r *CompanyScimConfig) GetByCompanyID(
|
||||
ctx context.Context,
|
||||
companyID *uuid.UUID,
|
||||
) (*model.CompanyScimConfig, error) {
|
||||
var row database.CompanyScimConfig
|
||||
res := r.DB.
|
||||
Where(
|
||||
fmt.Sprintf(
|
||||
"%s = ?",
|
||||
TableColumn(database.COMPANY_SCIM_CONFIG_TABLE, "company_id"),
|
||||
),
|
||||
companyID.String(),
|
||||
).
|
||||
First(&row)
|
||||
|
||||
if res.Error != nil {
|
||||
return nil, errs.Wrap(res.Error)
|
||||
}
|
||||
return ToCompanyScimConfig(&row), nil
|
||||
}
|
||||
|
||||
// GetByID fetches the SCIM config by its primary key
|
||||
func (r *CompanyScimConfig) GetByID(
|
||||
ctx context.Context,
|
||||
id *uuid.UUID,
|
||||
) (*model.CompanyScimConfig, error) {
|
||||
var row database.CompanyScimConfig
|
||||
res := r.DB.
|
||||
Where(
|
||||
fmt.Sprintf(
|
||||
"%s = ?",
|
||||
TableColumnID(database.COMPANY_SCIM_CONFIG_TABLE),
|
||||
),
|
||||
id.String(),
|
||||
).
|
||||
First(&row)
|
||||
|
||||
if res.Error != nil {
|
||||
return nil, errs.Wrap(res.Error)
|
||||
}
|
||||
return ToCompanyScimConfig(&row), nil
|
||||
}
|
||||
|
||||
// UpdateByID performs a partial update on the SCIM config via ToDBMap
|
||||
func (r *CompanyScimConfig) UpdateByID(
|
||||
ctx context.Context,
|
||||
id *uuid.UUID,
|
||||
config *model.CompanyScimConfig,
|
||||
) error {
|
||||
row := config.ToDBMap()
|
||||
AddUpdatedAt(row)
|
||||
|
||||
res := r.DB.
|
||||
Model(&database.CompanyScimConfig{}).
|
||||
Where(
|
||||
fmt.Sprintf(
|
||||
"%s = ?",
|
||||
TableColumnID(database.COMPANY_SCIM_CONFIG_TABLE),
|
||||
),
|
||||
id.String(),
|
||||
).
|
||||
Updates(row)
|
||||
|
||||
if res.Error != nil {
|
||||
return errs.Wrap(res.Error)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateTokenByID replaces the token hash and prefix and bumps updated_at
|
||||
func (r *CompanyScimConfig) UpdateTokenByID(
|
||||
ctx context.Context,
|
||||
id *uuid.UUID,
|
||||
tokenHash string,
|
||||
tokenPrefix string,
|
||||
) error {
|
||||
row := map[string]any{
|
||||
"token_hash": tokenHash,
|
||||
"token_prefix": tokenPrefix,
|
||||
}
|
||||
AddUpdatedAt(row)
|
||||
|
||||
res := r.DB.
|
||||
Model(&database.CompanyScimConfig{}).
|
||||
Where(
|
||||
fmt.Sprintf(
|
||||
"%s = ?",
|
||||
TableColumnID(database.COMPANY_SCIM_CONFIG_TABLE),
|
||||
),
|
||||
id.String(),
|
||||
).
|
||||
Updates(row)
|
||||
|
||||
if res.Error != nil {
|
||||
return errs.Wrap(res.Error)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateLastSyncAt sets last_sync_at to the current UTC time for the given config ID
|
||||
func (r *CompanyScimConfig) UpdateLastSyncAt(
|
||||
ctx context.Context,
|
||||
id *uuid.UUID,
|
||||
) error {
|
||||
now := time.Now().UTC()
|
||||
row := map[string]any{
|
||||
"last_sync_at": now,
|
||||
}
|
||||
AddUpdatedAt(row)
|
||||
|
||||
res := r.DB.
|
||||
Model(&database.CompanyScimConfig{}).
|
||||
Where(
|
||||
fmt.Sprintf(
|
||||
"%s = ?",
|
||||
TableColumnID(database.COMPANY_SCIM_CONFIG_TABLE),
|
||||
),
|
||||
id.String(),
|
||||
).
|
||||
Updates(row)
|
||||
|
||||
if res.Error != nil {
|
||||
return errs.Wrap(res.Error)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetTokenHashByCompanyID fetches only the token hash for the given company,
|
||||
// used during bearer-token verification so the hash never leaks into the model layer
|
||||
func (r *CompanyScimConfig) GetTokenHashByCompanyID(
|
||||
ctx context.Context,
|
||||
companyID *uuid.UUID,
|
||||
) (string, error) {
|
||||
var row database.CompanyScimConfig
|
||||
res := r.DB.
|
||||
Select("token_hash").
|
||||
Where(
|
||||
fmt.Sprintf(
|
||||
"%s = ?",
|
||||
TableColumn(database.COMPANY_SCIM_CONFIG_TABLE, "company_id"),
|
||||
),
|
||||
companyID.String(),
|
||||
).
|
||||
First(&row)
|
||||
|
||||
if res.Error != nil {
|
||||
return "", errs.Wrap(res.Error)
|
||||
}
|
||||
return row.TokenHash, nil
|
||||
}
|
||||
|
||||
// GetWithTokenHashByCompanyID fetches the full config row and the token hash in a
|
||||
// single query, used during bearer-token verification.
|
||||
func (r *CompanyScimConfig) GetWithTokenHashByCompanyID(
|
||||
ctx context.Context,
|
||||
companyID *uuid.UUID,
|
||||
) (*model.CompanyScimConfig, string, error) {
|
||||
var row database.CompanyScimConfig
|
||||
res := r.DB.
|
||||
Where(
|
||||
fmt.Sprintf(
|
||||
"%s = ?",
|
||||
TableColumn(database.COMPANY_SCIM_CONFIG_TABLE, "company_id"),
|
||||
),
|
||||
companyID.String(),
|
||||
).
|
||||
First(&row)
|
||||
|
||||
if res.Error != nil {
|
||||
return nil, "", errs.Wrap(res.Error)
|
||||
}
|
||||
return ToCompanyScimConfig(&row), row.TokenHash, nil
|
||||
}
|
||||
|
||||
// DeleteByCompanyID removes the SCIM config for a given company
|
||||
func (r *CompanyScimConfig) DeleteByCompanyID(
|
||||
ctx context.Context,
|
||||
companyID *uuid.UUID,
|
||||
) error {
|
||||
res := r.DB.
|
||||
Where(
|
||||
fmt.Sprintf(
|
||||
"%s = ?",
|
||||
TableColumn(database.COMPANY_SCIM_CONFIG_TABLE, "company_id"),
|
||||
),
|
||||
companyID.String(),
|
||||
).
|
||||
Delete(&database.CompanyScimConfig{})
|
||||
|
||||
if res.Error != nil {
|
||||
return errs.Wrap(res.Error)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ToCompanyScimConfig maps a database row to the business model.
|
||||
// the token field is intentionally left empty — it is never read back from storage.
|
||||
func ToCompanyScimConfig(row *database.CompanyScimConfig) *model.CompanyScimConfig {
|
||||
id := nullable.NewNullableWithValue(*row.ID)
|
||||
|
||||
companyID := nullable.NewNullNullable[uuid.UUID]()
|
||||
if row.CompanyID != nil {
|
||||
companyID.Set(*row.CompanyID)
|
||||
}
|
||||
|
||||
tokenPrefix := nullable.NewNullableWithValue(row.TokenPrefix)
|
||||
|
||||
return &model.CompanyScimConfig{
|
||||
ID: id,
|
||||
CreatedAt: row.CreatedAt,
|
||||
UpdatedAt: row.UpdatedAt,
|
||||
CompanyID: companyID,
|
||||
TokenPrefix: tokenPrefix,
|
||||
Enabled: row.Enabled,
|
||||
LastSyncAt: row.LastSyncAt,
|
||||
}
|
||||
}
|
||||
@@ -839,6 +839,9 @@ func ToRecipient(row *database.Recipient) (*model.Recipient, error) {
|
||||
misc := nullable.NewNullableWithValue(
|
||||
*vo.NewOptionalString127Must(row.Misc),
|
||||
)
|
||||
scimUserName := nullable.NewNullableWithValue(
|
||||
*vo.NewOptionalString127Must(row.ScimUserName),
|
||||
)
|
||||
var company *model.Company
|
||||
if row.Company != nil {
|
||||
company = ToCompany(row.Company)
|
||||
@@ -869,6 +872,7 @@ func ToRecipient(row *database.Recipient) (*model.Recipient, error) {
|
||||
City: city,
|
||||
Country: country,
|
||||
Misc: misc,
|
||||
ScimUserName: scimUserName,
|
||||
Company: company,
|
||||
Groups: nullable.NewNullableWithValue(groups),
|
||||
}, nil
|
||||
|
||||
@@ -13,6 +13,32 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// IsRecipientInGroup returns true when the given recipient is a member of the given group.
|
||||
// uses a COUNT query so it is O(1) regardless of group size.
|
||||
func (rg *RecipientGroup) IsRecipientInGroup(
|
||||
ctx context.Context,
|
||||
groupID *uuid.UUID,
|
||||
recipientID *uuid.UUID,
|
||||
) (bool, error) {
|
||||
var count int64
|
||||
res := rg.DB.
|
||||
Model(&database.RecipientGroupRecipient{}).
|
||||
Where(
|
||||
fmt.Sprintf(
|
||||
"%s = ? AND %s = ?",
|
||||
TableColumn(database.RECIPIENT_GROUP_RECIPIENT_TABLE, "recipient_group_id"),
|
||||
TableColumn(database.RECIPIENT_GROUP_RECIPIENT_TABLE, "recipient_id"),
|
||||
),
|
||||
groupID.String(),
|
||||
recipientID.String(),
|
||||
).
|
||||
Count(&count)
|
||||
if res.Error != nil {
|
||||
return false, errs.Wrap(res.Error)
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
var RecipientGroupAllowedColumns = assignTableToColumns(database.RECIPIENT_GROUP_TABLE, []string{
|
||||
"created_at",
|
||||
"updated_at",
|
||||
|
||||
@@ -58,6 +58,7 @@ func initialInstallAndSeed(
|
||||
&database.OAuthProvider{},
|
||||
&database.OAuthState{},
|
||||
&database.MicrosoftDeviceCode{},
|
||||
&database.CompanyScimConfig{},
|
||||
}
|
||||
|
||||
// disable foreign key constraints temporarily for sqlite to allow table recreation
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
|
||||
"github.com/go-errors/errors"
|
||||
"github.com/google/uuid"
|
||||
"github.com/oapi-codegen/nullable"
|
||||
"github.com/phishingclub/phishingclub/data"
|
||||
"github.com/phishingclub/phishingclub/errs"
|
||||
"github.com/phishingclub/phishingclub/model"
|
||||
"github.com/phishingclub/phishingclub/repository"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// CompanyScimConfig is the service for SCIM configuration per company
|
||||
type CompanyScimConfig struct {
|
||||
Common
|
||||
CompanyScimConfigRepository *repository.CompanyScimConfig
|
||||
}
|
||||
|
||||
// generateToken creates a cryptographically random 32-byte token and returns
|
||||
// the hex-encoded plain token and its 8-character prefix
|
||||
func generateScimToken() (plain string, prefix string, err error) {
|
||||
buf := make([]byte, 32)
|
||||
if _, err = rand.Read(buf); err != nil {
|
||||
return "", "", fmt.Errorf("failed to generate random token: %w", err)
|
||||
}
|
||||
plain = hex.EncodeToString(buf)
|
||||
prefix = plain[:8]
|
||||
return plain, prefix, nil
|
||||
}
|
||||
|
||||
// hashScimToken bcrypt-hashes the plain token with cost 12
|
||||
func hashScimToken(plain string) (string, error) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(plain), 12)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to hash token: %w", err)
|
||||
}
|
||||
return string(hash), nil
|
||||
}
|
||||
|
||||
// GetByCompanyID returns the SCIM config for the given company
|
||||
func (s *CompanyScimConfig) GetByCompanyID(
|
||||
ctx context.Context,
|
||||
session *model.Session,
|
||||
companyID *uuid.UUID,
|
||||
) (*model.CompanyScimConfig, error) {
|
||||
ae := NewAuditEvent("CompanyScimConfig.GetByCompanyID", session)
|
||||
ae.Details["companyID"] = companyID.String()
|
||||
// check permissions
|
||||
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
|
||||
if err != nil {
|
||||
s.LogAuthError(err)
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
if !isAuthorized {
|
||||
s.AuditLogNotAuthorized(ae)
|
||||
return nil, errs.ErrAuthorizationFailed
|
||||
}
|
||||
// get
|
||||
config, err := s.CompanyScimConfigRepository.GetByCompanyID(ctx, companyID)
|
||||
if err != nil {
|
||||
s.Logger.Errorw("failed to get scim config by company id", "error", err)
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
// no audit on read
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// Upsert creates or updates the SCIM configuration for the given company.
|
||||
// when creating, a new token is generated and returned in plain text (shown once).
|
||||
// when updating, the token is NOT rotated.
|
||||
func (s *CompanyScimConfig) Upsert(
|
||||
ctx context.Context,
|
||||
session *model.Session,
|
||||
companyID *uuid.UUID,
|
||||
enabled bool,
|
||||
) (*model.CompanyScimConfig, error) {
|
||||
ae := NewAuditEvent("CompanyScimConfig.Upsert", session)
|
||||
ae.Details["companyID"] = companyID.String()
|
||||
// check permissions
|
||||
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
|
||||
if err != nil {
|
||||
s.LogAuthError(err)
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
if !isAuthorized {
|
||||
s.AuditLogNotAuthorized(ae)
|
||||
return nil, errs.ErrAuthorizationFailed
|
||||
}
|
||||
// check if a config already exists for this company
|
||||
existing, err := s.CompanyScimConfigRepository.GetByCompanyID(ctx, companyID)
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
s.Logger.Errorw("failed to look up existing scim config", "error", err)
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
if existing != nil {
|
||||
// update enabled and linked group only; do not regenerate the token
|
||||
existing.Enabled = enabled
|
||||
existingID, idErr := existing.ID.Get()
|
||||
if idErr != nil {
|
||||
s.Logger.Errorw("scim config has no id", "error", idErr)
|
||||
return nil, errs.Wrap(idErr)
|
||||
}
|
||||
if err := s.CompanyScimConfigRepository.UpdateByID(ctx, &existingID, existing); err != nil {
|
||||
s.Logger.Errorw("failed to update scim config", "error", err)
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
ae.Details["id"] = existingID.String()
|
||||
ae.Details["action"] = "update"
|
||||
s.AuditLogAuthorized(ae)
|
||||
return existing, nil
|
||||
}
|
||||
// create: generate a fresh token
|
||||
plain, prefix, err := generateScimToken()
|
||||
if err != nil {
|
||||
s.Logger.Errorw("failed to generate scim token", "error", err)
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
tokenHash, err := hashScimToken(plain)
|
||||
if err != nil {
|
||||
s.Logger.Errorw("failed to hash scim token", "error", err)
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
config := &model.CompanyScimConfig{
|
||||
CompanyID: nullable.NewNullableWithValue(*companyID),
|
||||
Enabled: enabled,
|
||||
TokenPrefix: nullable.NewNullableWithValue(prefix),
|
||||
}
|
||||
id, err := s.CompanyScimConfigRepository.Insert(ctx, config, tokenHash)
|
||||
if err != nil {
|
||||
s.Logger.Errorw("failed to insert scim config", "error", err)
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
ae.Details["id"] = id.String()
|
||||
ae.Details["action"] = "insert"
|
||||
s.AuditLogAuthorized(ae)
|
||||
// load the persisted record and attach the plain token for one-time display
|
||||
created, err := s.CompanyScimConfigRepository.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
s.Logger.Errorw("failed to fetch newly inserted scim config", "error", err)
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
// set plain token on model for one-time display; it is never persisted
|
||||
created.Token = plain
|
||||
|
||||
return created, nil
|
||||
}
|
||||
|
||||
// RotateToken generates a new token for the existing SCIM config identified by
|
||||
// companyID. the plain token is returned once so the caller can present it to
|
||||
// the user; only the hash is persisted.
|
||||
func (s *CompanyScimConfig) RotateToken(
|
||||
ctx context.Context,
|
||||
session *model.Session,
|
||||
companyID *uuid.UUID,
|
||||
) (*model.CompanyScimConfig, error) {
|
||||
ae := NewAuditEvent("CompanyScimConfig.RotateToken", session)
|
||||
ae.Details["companyID"] = companyID.String()
|
||||
// check permissions
|
||||
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
|
||||
if err != nil {
|
||||
s.LogAuthError(err)
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
if !isAuthorized {
|
||||
s.AuditLogNotAuthorized(ae)
|
||||
return nil, errs.ErrAuthorizationFailed
|
||||
}
|
||||
// fetch existing config
|
||||
config, err := s.CompanyScimConfigRepository.GetByCompanyID(ctx, companyID)
|
||||
if err != nil {
|
||||
s.Logger.Errorw("failed to get scim config for token rotation", "error", err)
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
configID, idErr := config.ID.Get()
|
||||
if idErr != nil {
|
||||
s.Logger.Errorw("scim config has no id", "error", idErr)
|
||||
return nil, errs.Wrap(idErr)
|
||||
}
|
||||
// generate a new token
|
||||
plain, prefix, err := generateScimToken()
|
||||
if err != nil {
|
||||
s.Logger.Errorw("failed to generate new scim token", "error", err)
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
tokenHash, err := hashScimToken(plain)
|
||||
if err != nil {
|
||||
s.Logger.Errorw("failed to hash new scim token", "error", err)
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
if err := s.CompanyScimConfigRepository.UpdateTokenByID(ctx, &configID, tokenHash, prefix); err != nil {
|
||||
s.Logger.Errorw("failed to update scim token", "error", err)
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
ae.Details["id"] = configID.String()
|
||||
s.AuditLogAuthorized(ae)
|
||||
// reload and return with plain token set for one-time display
|
||||
updated, err := s.CompanyScimConfigRepository.GetByID(ctx, &configID)
|
||||
if err != nil {
|
||||
s.Logger.Errorw("failed to fetch scim config after token rotation", "error", err)
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
// set plain token on model for one-time display; it is never persisted
|
||||
updated.Token = plain
|
||||
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
// DeleteByCompanyID removes the SCIM configuration for the given company
|
||||
func (s *CompanyScimConfig) DeleteByCompanyID(
|
||||
ctx context.Context,
|
||||
session *model.Session,
|
||||
companyID *uuid.UUID,
|
||||
) error {
|
||||
ae := NewAuditEvent("CompanyScimConfig.DeleteByCompanyID", session)
|
||||
ae.Details["companyID"] = companyID.String()
|
||||
// check permissions
|
||||
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
|
||||
if err != nil {
|
||||
s.LogAuthError(err)
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
if !isAuthorized {
|
||||
s.AuditLogNotAuthorized(ae)
|
||||
return errs.ErrAuthorizationFailed
|
||||
}
|
||||
// delete
|
||||
if err := s.CompanyScimConfigRepository.DeleteByCompanyID(ctx, companyID); err != nil {
|
||||
s.Logger.Errorw("failed to delete scim config", "error", err)
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
s.AuditLogAuthorized(ae)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// VerifyToken checks whether the supplied plain token matches the stored bcrypt
|
||||
// hash for the given company. no session or permission check is performed
|
||||
// because this is called by the inbound SCIM handler during bearer-token auth.
|
||||
func (s *CompanyScimConfig) VerifyToken(
|
||||
ctx context.Context,
|
||||
companyID *uuid.UUID,
|
||||
plainToken string,
|
||||
) (bool, *model.CompanyScimConfig, error) {
|
||||
// fetch config and token hash in a single round-trip
|
||||
config, tokenHash, err := s.CompanyScimConfigRepository.GetWithTokenHashByCompanyID(ctx, companyID)
|
||||
if err != nil {
|
||||
s.Logger.Errorw("failed to get scim config for token verification", "error", err)
|
||||
return false, nil, errs.Wrap(err)
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(tokenHash), []byte(plainToken)); err != nil {
|
||||
if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) {
|
||||
return false, config, nil
|
||||
}
|
||||
s.Logger.Errorw("failed to compare scim token hash", "error", err)
|
||||
return false, config, errs.Wrap(err)
|
||||
}
|
||||
|
||||
return true, config, nil
|
||||
}
|
||||
@@ -243,6 +243,7 @@ func (r *RecipientGroup) GetAll(
|
||||
r.AuditLogNotAuthorized(ae)
|
||||
return result, errs.ErrAuthorizationFailed
|
||||
}
|
||||
|
||||
// get recipient groups
|
||||
result, err = r.RecipientGroupRepository.GetAll(
|
||||
ctx,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1241,6 +1241,28 @@ export class API {
|
||||
*/
|
||||
delete: async (id) => {
|
||||
return await deleteJSON(this.getPath(`/company/${id}`));
|
||||
},
|
||||
|
||||
scim: {
|
||||
// get the scim config for a company
|
||||
getByCompanyID: async (companyID) => {
|
||||
return await getJSON(this.getPath(`/company/scim/${companyID}`));
|
||||
},
|
||||
// upsert the scim config for a company
|
||||
upsert: async (companyID, { linkedGroupID, enabled }) => {
|
||||
return await postJSON(this.getPath(`/company/scim/${companyID}`), {
|
||||
linkedGroupID: linkedGroupID ?? null,
|
||||
enabled: enabled
|
||||
});
|
||||
},
|
||||
// rotate the scim bearer token for a company
|
||||
rotateToken: async (companyID) => {
|
||||
return await postJSON(this.getPath(`/company/scim/${companyID}/token`), {});
|
||||
},
|
||||
// delete the scim config for a company
|
||||
delete: async (companyID) => {
|
||||
return await deleteJSON(this.getPath(`/company/scim/${companyID}`));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,21 +1,32 @@
|
||||
import { API } from '$lib/api/api.js';
|
||||
import { immediateResponseHandler } from '$lib/api/middleware';
|
||||
|
||||
// api singleton where each response is proxyed to the responseHandler
|
||||
export const api = new Proxy(API.instance, {
|
||||
get: function(target, prop) {
|
||||
const apiSection = target[prop];
|
||||
const apiSectionProxy = new Proxy(apiSection, {
|
||||
get: function(target, prop) {
|
||||
const method = new Proxy(target[prop], {
|
||||
apply: async (target, _, argumentsList) => {
|
||||
return immediateResponseHandler(await target(...argumentsList));
|
||||
}
|
||||
});
|
||||
return method
|
||||
}
|
||||
});
|
||||
return apiSectionProxy;
|
||||
}
|
||||
});
|
||||
// wrap a single async function with the response handler
|
||||
const wrapMethod = (fn) =>
|
||||
new Proxy(fn, {
|
||||
apply: async (target, _, argumentsList) => {
|
||||
return immediateResponseHandler(await target(...argumentsList));
|
||||
}
|
||||
});
|
||||
|
||||
// wrap a section (one level of methods, possibly with nested sub-objects)
|
||||
const wrapSection = (section) =>
|
||||
new Proxy(section, {
|
||||
get: function (target, prop) {
|
||||
const value = target[prop];
|
||||
// if the value is a plain object (nested sub-section like scim), wrap it recursively
|
||||
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
||||
return wrapSection(value);
|
||||
}
|
||||
// otherwise it is a method — wrap it with the response handler
|
||||
return wrapMethod(value);
|
||||
}
|
||||
});
|
||||
|
||||
// api singleton where each response is proxied to the responseHandler
|
||||
export const api = new Proxy(API.instance, {
|
||||
get: function (target, prop) {
|
||||
const apiSection = target[prop];
|
||||
return wrapSection(apiSection);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
<script>
|
||||
import { addToast } from '$lib/store/toast';
|
||||
import Modal from '../Modal.svelte';
|
||||
import Alert from '../Alert.svelte';
|
||||
import { api } from '$lib/api/apiProxy.js';
|
||||
|
||||
// external
|
||||
export let visible = false;
|
||||
/** @type {{ id: string, name: string } | null} */
|
||||
export let company = null;
|
||||
|
||||
// local state
|
||||
let scimConfig = null;
|
||||
let isLoading = false;
|
||||
let isTogglingEnabled = false;
|
||||
let isRotating = false;
|
||||
let isSettingUp = false;
|
||||
let isDeleting = false;
|
||||
|
||||
// token reveal — only populated immediately after create or rotate
|
||||
let revealedToken = '';
|
||||
let showTokenReveal = false;
|
||||
|
||||
// confirmation dialogs
|
||||
let isRotateAlertVisible = false;
|
||||
let isDeleteAlertVisible = false;
|
||||
|
||||
// reactive: reload when modal opens
|
||||
$: {
|
||||
if (visible && company) {
|
||||
loadAll();
|
||||
}
|
||||
}
|
||||
|
||||
// reactive: clean up when modal closes
|
||||
$: {
|
||||
if (!visible) {
|
||||
resetState();
|
||||
}
|
||||
}
|
||||
|
||||
const resetState = () => {
|
||||
scimConfig = null;
|
||||
revealedToken = '';
|
||||
showTokenReveal = false;
|
||||
isRotateAlertVisible = false;
|
||||
isDeleteAlertVisible = false;
|
||||
};
|
||||
|
||||
const loadAll = async () => {
|
||||
isLoading = true;
|
||||
try {
|
||||
await loadScimConfig();
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadScimConfig = async () => {
|
||||
try {
|
||||
const res = await api.company.scim.getByCompanyID(company.id);
|
||||
if (res && res.success && res.data) {
|
||||
scimConfig = res.data;
|
||||
} else {
|
||||
scimConfig = null;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('failed to load scim config', e);
|
||||
scimConfig = null;
|
||||
}
|
||||
};
|
||||
|
||||
// called once — creates the config and reveals the token
|
||||
const onSetUp = async () => {
|
||||
isSettingUp = true;
|
||||
try {
|
||||
const res = await api.company.scim.upsert(company.id, { enabled: true });
|
||||
if (!res || !res.success) {
|
||||
addToast(res?.error ?? 'Failed to set up SCIM', 'Error');
|
||||
return;
|
||||
}
|
||||
scimConfig = res.data;
|
||||
if (res.data?.token) {
|
||||
revealedToken = res.data.token;
|
||||
showTokenReveal = true;
|
||||
}
|
||||
addToast('SCIM set up', 'Success');
|
||||
} catch (e) {
|
||||
console.error('failed to set up scim', e);
|
||||
addToast('Failed to set up SCIM', 'Error');
|
||||
} finally {
|
||||
isSettingUp = false;
|
||||
}
|
||||
};
|
||||
|
||||
// inline toggle — immediately persists the new enabled state
|
||||
const onToggleEnabled = async () => {
|
||||
if (!scimConfig) return;
|
||||
isTogglingEnabled = true;
|
||||
const newEnabled = !scimConfig.enabled;
|
||||
try {
|
||||
const res = await api.company.scim.upsert(company.id, { enabled: newEnabled });
|
||||
if (!res || !res.success) {
|
||||
addToast(res?.error ?? 'Failed to update SCIM', 'Error');
|
||||
return;
|
||||
}
|
||||
scimConfig = res.data;
|
||||
addToast(newEnabled ? 'SCIM enabled' : 'SCIM disabled', 'Success');
|
||||
} catch (e) {
|
||||
console.error('failed to toggle scim enabled', e);
|
||||
addToast('Failed to update SCIM', 'Error');
|
||||
} finally {
|
||||
isTogglingEnabled = false;
|
||||
}
|
||||
};
|
||||
|
||||
const onConfirmRotateToken = async () => {
|
||||
isRotating = true;
|
||||
try {
|
||||
const res = await api.company.scim.rotateToken(company.id);
|
||||
if (!res || !res.success) {
|
||||
addToast(res?.error ?? 'Failed to rotate token', 'Error');
|
||||
return { success: false };
|
||||
}
|
||||
scimConfig = res.data;
|
||||
revealedToken = res.data?.token ?? '';
|
||||
showTokenReveal = true;
|
||||
addToast('SCIM token rotated', 'Success');
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
console.error('failed to rotate scim token', e);
|
||||
addToast('Failed to rotate SCIM token', 'Error');
|
||||
return { success: false };
|
||||
} finally {
|
||||
isRotating = false;
|
||||
}
|
||||
};
|
||||
|
||||
const onConfirmDelete = async () => {
|
||||
isDeleting = true;
|
||||
try {
|
||||
const res = await api.company.scim.delete(company.id);
|
||||
if (!res || !res.success) {
|
||||
addToast(res?.error ?? 'Failed to delete SCIM config', 'Error');
|
||||
return { success: false };
|
||||
}
|
||||
scimConfig = null;
|
||||
showTokenReveal = false;
|
||||
revealedToken = '';
|
||||
addToast('SCIM config deleted', 'Success');
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
console.error('failed to delete scim config', e);
|
||||
addToast('Failed to delete SCIM config', 'Error');
|
||||
return { success: false };
|
||||
} finally {
|
||||
isDeleting = false;
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = async (text) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
addToast('Copied to clipboard', 'Success');
|
||||
} catch (e) {
|
||||
console.error('failed to copy to clipboard', e);
|
||||
}
|
||||
};
|
||||
|
||||
const dismissToken = () => {
|
||||
showTokenReveal = false;
|
||||
revealedToken = '';
|
||||
};
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return 'Never';
|
||||
try {
|
||||
return new Date(dateStr).toLocaleString();
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
};
|
||||
|
||||
$: scimBaseURL = company
|
||||
? `${typeof window !== 'undefined' ? window.location.origin : ''}/api/v1/scim/v2/${company.id}`
|
||||
: '';
|
||||
|
||||
$: isBusy = isSettingUp || isTogglingEnabled || isRotating || isDeleting;
|
||||
</script>
|
||||
|
||||
<Modal headerText={`SCIM`} bind:visible>
|
||||
<div class="w-[600px] p-6 space-y-6">
|
||||
{#if isLoading}
|
||||
<div class="flex items-center justify-center py-10">
|
||||
<p class="text-gray-500 dark:text-gray-400 transition-colors duration-200">Loading...</p>
|
||||
</div>
|
||||
{:else if showTokenReveal && revealedToken}
|
||||
<!-- ── step 2: token reveal — nothing else until dismissed ── -->
|
||||
<div
|
||||
class="rounded-md border border-amber-400 dark:border-amber-500/60 bg-amber-50 dark:bg-amber-900/20 p-4 space-y-3 transition-colors duration-200"
|
||||
>
|
||||
<p class="text-sm font-semibold text-amber-700 dark:text-amber-400">
|
||||
⚠ Copy this token now — it will not be shown again.
|
||||
</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
readonly
|
||||
value={revealedToken}
|
||||
class="flex-1 px-3 py-2 text-sm rounded-md bg-white dark:bg-gray-900/60 border border-amber-300 dark:border-amber-600/60 text-gray-800 dark:text-gray-200 font-mono focus:outline-none transition-colors duration-200"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => copyToClipboard(revealedToken)}
|
||||
class="px-3 py-2 text-sm bg-amber-500 hover:bg-amber-400 dark:bg-amber-600/80 dark:hover:bg-amber-500/80 text-white rounded-md transition-colors duration-200"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs text-amber-600 dark:text-amber-500 underline hover:no-underline"
|
||||
on:click={dismissToken}
|
||||
>
|
||||
I have copied the token, dismiss
|
||||
</button>
|
||||
</div>
|
||||
{:else if !scimConfig}
|
||||
<!-- ── step 1: no config yet ── -->
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 transition-colors duration-200">
|
||||
Set up SCIM provisioning for <strong class="text-gray-700 dark:text-gray-300"
|
||||
>{company?.name}</strong
|
||||
>. <br /> A bearer token will be generated once and must be copied into your identity provider.
|
||||
</p>
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
on:click={onSetUp}
|
||||
class="bg-cta-blue dark:bg-highlight-blue/80 hover:bg-blue-700 dark:hover:bg-highlight-blue text-sm uppercase font-bold px-4 py-2 text-white rounded-md disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
|
||||
>
|
||||
{isSettingUp ? 'Setting up...' : 'Configure SCIM'}
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- ── step 3: config exists ── -->
|
||||
|
||||
<!-- status panel -->
|
||||
<div
|
||||
class="rounded-md border border-gray-200 dark:border-gray-700/60 p-4 space-y-3 transition-colors duration-200"
|
||||
>
|
||||
<div class="grid grid-cols-2 gap-x-6 gap-y-2 text-sm">
|
||||
<span class="text-gray-500 dark:text-gray-500">Token prefix</span>
|
||||
<span class="text-gray-800 dark:text-gray-200 font-mono">
|
||||
{scimConfig.tokenPrefix ? scimConfig.tokenPrefix + '...' : '—'}
|
||||
</span>
|
||||
<span class="text-gray-500 dark:text-gray-500">Last sync</span>
|
||||
<span class="text-gray-800 dark:text-gray-200">{formatDate(scimConfig.lastSyncAt)}</span>
|
||||
</div>
|
||||
|
||||
<!-- scim base url -->
|
||||
<div class="pt-3 border-t border-gray-200 dark:border-gray-700/60 space-y-1">
|
||||
<p class="text-xs font-semibold text-gray-500 dark:text-gray-400">
|
||||
SCIM Base URL - provide this to your identity provider
|
||||
</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
readonly
|
||||
value={scimBaseURL}
|
||||
class="flex-1 px-3 py-2 text-xs rounded-md bg-gray-50 dark:bg-gray-900/60 border border-gray-200 dark:border-gray-700/60 text-gray-700 dark:text-gray-300 font-mono focus:outline-none transition-colors duration-200"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => copyToClipboard(scimBaseURL)}
|
||||
class="px-3 py-2 text-sm bg-slate-500 hover:bg-slate-400 dark:bg-gray-700/80 dark:hover:bg-gray-600/80 text-white rounded-md transition-colors duration-200"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- enabled toggle -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">Provisioning</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{scimConfig.enabled
|
||||
? 'Active - IdP can push changes.'
|
||||
: 'Paused - incoming SCIM requests will be rejected.'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={scimConfig.enabled}
|
||||
disabled={isBusy}
|
||||
on:click={onToggleEnabled}
|
||||
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed
|
||||
{scimConfig.enabled ? 'bg-cta-blue dark:bg-highlight-blue/80' : 'bg-gray-300 dark:bg-gray-600'}"
|
||||
>
|
||||
<span
|
||||
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200
|
||||
{scimConfig.enabled ? 'translate-x-5' : 'translate-x-0'}"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- actions -->
|
||||
<div
|
||||
class="border-t border-gray-200 dark:border-gray-700/60 pt-4 flex gap-3 justify-end transition-colors duration-200"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
on:click={() => (isRotateAlertVisible = true)}
|
||||
class="bg-slate-400 dark:bg-gray-700/80 hover:bg-slate-300 dark:hover:bg-gray-600/80 text-sm uppercase font-bold px-4 py-2 text-white rounded-md disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
|
||||
>
|
||||
Rotate Token
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
on:click={() => (isDeleteAlertVisible = true)}
|
||||
class="bg-red-600 dark:bg-red-700/80 hover:bg-red-500 dark:hover:bg-red-600/80 text-sm uppercase font-bold px-4 py-2 text-white rounded-md disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<!-- rotate token confirmation -->
|
||||
<Alert
|
||||
headline="Rotate SCIM Token"
|
||||
bind:visible={isRotateAlertVisible}
|
||||
onConfirm={onConfirmRotateToken}
|
||||
>
|
||||
<p>
|
||||
Are you sure you want to rotate the SCIM bearer token for
|
||||
<strong>{company?.name}</strong>?
|
||||
</p>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
the existing token will be immediately invalidated. your identity provider must be updated with
|
||||
the new token before provisioning can resume.
|
||||
</p>
|
||||
</Alert>
|
||||
|
||||
<!-- delete config confirmation -->
|
||||
<Alert
|
||||
headline="Delete SCIM Config"
|
||||
bind:visible={isDeleteAlertVisible}
|
||||
onConfirm={onConfirmDelete}
|
||||
>
|
||||
<p>
|
||||
Are you sure you want to delete the SCIM configuration for
|
||||
<strong>{company?.name}</strong>?
|
||||
</p>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
the bearer token will be invalidated and future syncs will stop. previously provisioned
|
||||
recipients will not be removed.
|
||||
</p>
|
||||
</Alert>
|
||||
@@ -22,6 +22,7 @@
|
||||
import DeleteAlert from '$lib/components/modal/DeleteAlert.svelte';
|
||||
import TableDropDownButton from '$lib/components/table/TableDropDownButton.svelte';
|
||||
import Alert from '$lib/components/Alert.svelte';
|
||||
import ScimModal from '$lib/components/modal/ScimModal.svelte';
|
||||
|
||||
// bindings
|
||||
let form = null;
|
||||
@@ -53,6 +54,9 @@
|
||||
let isExportSharedModalVisible = false;
|
||||
let exportCompany = null;
|
||||
|
||||
let isScimModalVisible = false;
|
||||
let scimCompany = null;
|
||||
|
||||
$: {
|
||||
modalText = modalMode === 'create' ? 'New company' : 'Update company';
|
||||
}
|
||||
@@ -263,6 +267,16 @@
|
||||
isExportSharedModalVisible = false;
|
||||
};
|
||||
|
||||
const openScimModal = (company) => {
|
||||
scimCompany = company;
|
||||
isScimModalVisible = true;
|
||||
};
|
||||
|
||||
const closeScimModal = () => {
|
||||
isScimModalVisible = false;
|
||||
scimCompany = null;
|
||||
};
|
||||
|
||||
const onConfirmExportCompany = async () => {
|
||||
try {
|
||||
showIsLoading();
|
||||
@@ -329,6 +343,7 @@
|
||||
on:click={() => openViewCommentModal(company)}
|
||||
/>
|
||||
<TableDropDownButton name="Export" on:click={() => openExportCompanyModal(company)} />
|
||||
<TableDropDownButton name="SCIM" on:click={() => openScimModal(company)} />
|
||||
<TableDropDownButton
|
||||
name="Custom Stats"
|
||||
on:click={() => goto(`/company/${company.id}/stats`)}
|
||||
@@ -457,6 +472,8 @@
|
||||
</div>
|
||||
</Alert>
|
||||
|
||||
<ScimModal bind:visible={isScimModalVisible} company={scimCompany} />
|
||||
|
||||
<Alert
|
||||
headline="Export Shared Data"
|
||||
bind:visible={isExportSharedModalVisible}
|
||||
|
||||
@@ -243,6 +243,13 @@
|
||||
<TableRow>
|
||||
<TableCellLink href={`/recipient/group/${group.id}`} title={group.name}>
|
||||
{group.name}
|
||||
{#if group.scimEnabled}
|
||||
<span
|
||||
class="ml-2 inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300"
|
||||
>
|
||||
SCIM
|
||||
</span>
|
||||
{/if}
|
||||
</TableCellLink>
|
||||
|
||||
<TableCellLink href={`/recipient/group/${group.id}`} title={group.recipientCount}>
|
||||
|
||||
Reference in New Issue
Block a user