SCIM for companies

Signed-off-by: Ronni Skansing <rskansing@gmail.com>
This commit is contained in:
Ronni Skansing
2026-03-26 21:29:53 +01:00
parent 4f934bcb3e
commit 3aa3f817dd
24 changed files with 4165 additions and 28 deletions
+47 -3
View File
@@ -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).
+12
View File
@@ -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,
}
}
+2
View File
@@ -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},
}
}
+16
View File
@@ -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,
+130
View File
@@ -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{})
}
+677
View File
@@ -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
}
+34
View File
@@ -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
}
+8 -7
View File
@@ -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;"`
+34
View File
@@ -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
View File
@@ -321,7 +321,7 @@ func main() {
"trusted_ip_header", conf.IPSecurity.TrustedIPHeader,
)
}
adminRouter.Use(middlewares.IPLimiter)
adminServer := app.NewAdministrationServer(
adminRouter,
controllers,
+33
View File
@@ -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
}
+7
View File
@@ -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
+275
View File
@@ -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,
}
}
+4
View File
@@ -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
+26
View File
@@ -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",
+1
View File
@@ -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
+267
View File
@@ -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
}
+1
View File
@@ -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
+22
View File
@@ -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}`));
}
}
};
+28 -17
View File
@@ -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>
+17
View File
@@ -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}>