From 3aa3f817dd88dc32e1857a584bde19e8b3fac0f2 Mon Sep 17 00:00:00 2001 From: Ronni Skansing Date: Thu, 26 Mar 2026 21:29:53 +0100 Subject: [PATCH] SCIM for companies Signed-off-by: Ronni Skansing --- backend/app/administration.go | 50 +- backend/app/controllers.go | 12 + backend/app/repositories.go | 2 + backend/app/services.go | 16 + backend/controller/companyScimConfig.go | 130 + backend/controller/scim.go | 677 ++++++ backend/database/companyScimConfig.go | 34 + backend/database/recipient.go | 15 +- backend/errs/all.go | 34 + backend/main.go | 2 +- backend/model/companyScimConfig.go | 33 + backend/model/recipient.go | 7 + backend/repository/companyScimConfig.go | 275 +++ backend/repository/recipient.go | 4 + backend/repository/recipientGroup.go | 26 + backend/seed/migrate.go | 1 + backend/service/companyScimConfig.go | 267 ++ backend/service/recipientGroup.go | 1 + backend/service/scim.go | 2151 +++++++++++++++++ frontend/src/lib/api/api.js | 22 + frontend/src/lib/api/apiProxy.js | 45 +- .../src/lib/components/modal/ScimModal.svelte | 365 +++ frontend/src/routes/company/+page.svelte | 17 + .../src/routes/recipient/group/+page.svelte | 7 + 24 files changed, 4165 insertions(+), 28 deletions(-) create mode 100644 backend/controller/companyScimConfig.go create mode 100644 backend/controller/scim.go create mode 100644 backend/database/companyScimConfig.go create mode 100644 backend/model/companyScimConfig.go create mode 100644 backend/repository/companyScimConfig.go create mode 100644 backend/service/companyScimConfig.go create mode 100644 backend/service/scim.go create mode 100644 frontend/src/lib/components/modal/ScimModal.svelte diff --git a/backend/app/administration.go b/backend/app/administration.go index 5879e5e..28f799c 100644 --- a/backend/app/administration.go +++ b/backend/app/administration.go @@ -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). diff --git a/backend/app/controllers.go b/backend/app/controllers.go index 48919ce..81886d1 100644 --- a/backend/app/controllers.go +++ b/backend/app/controllers.go @@ -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, } } diff --git a/backend/app/repositories.go b/backend/app/repositories.go index a8d5ee6..8dca348 100644 --- a/backend/app/repositories.go +++ b/backend/app/repositories.go @@ -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}, } } diff --git a/backend/app/services.go b/backend/app/services.go index 31270a6..794ba0c 100644 --- a/backend/app/services.go +++ b/backend/app/services.go @@ -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, diff --git a/backend/controller/companyScimConfig.go b/backend/controller/companyScimConfig.go new file mode 100644 index 0000000..0ce05b1 --- /dev/null +++ b/backend/controller/companyScimConfig.go @@ -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{}) +} diff --git a/backend/controller/scim.go b/backend/controller/scim.go new file mode 100644 index 0000000..b8f03ea --- /dev/null +++ b/backend/controller/scim.go @@ -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 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 +} diff --git a/backend/database/companyScimConfig.go b/backend/database/companyScimConfig.go new file mode 100644 index 0000000..ba148ac --- /dev/null +++ b/backend/database/companyScimConfig.go @@ -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 +} diff --git a/backend/database/recipient.go b/backend/database/recipient.go index f04e1a8..f84b1c0 100644 --- a/backend/database/recipient.go +++ b/backend/database/recipient.go @@ -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;"` diff --git a/backend/errs/all.go b/backend/errs/all.go index 7cfefd8..c72e727 100644 --- a/backend/errs/all.go +++ b/backend/errs/all.go @@ -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 diff --git a/backend/main.go b/backend/main.go index fc5aef6..a8a2e04 100644 --- a/backend/main.go +++ b/backend/main.go @@ -321,7 +321,7 @@ func main() { "trusted_ip_header", conf.IPSecurity.TrustedIPHeader, ) } - adminRouter.Use(middlewares.IPLimiter) + adminServer := app.NewAdministrationServer( adminRouter, controllers, diff --git a/backend/model/companyScimConfig.go b/backend/model/companyScimConfig.go new file mode 100644 index 0000000..e0615bc --- /dev/null +++ b/backend/model/companyScimConfig.go @@ -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 +} diff --git a/backend/model/recipient.go b/backend/model/recipient.go index c713949..9964bd8 100644 --- a/backend/model/recipient.go +++ b/backend/model/recipient.go @@ -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 diff --git a/backend/repository/companyScimConfig.go b/backend/repository/companyScimConfig.go new file mode 100644 index 0000000..ef356e0 --- /dev/null +++ b/backend/repository/companyScimConfig.go @@ -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, + } +} diff --git a/backend/repository/recipient.go b/backend/repository/recipient.go index ab987f2..eaca562 100644 --- a/backend/repository/recipient.go +++ b/backend/repository/recipient.go @@ -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 diff --git a/backend/repository/recipientGroup.go b/backend/repository/recipientGroup.go index ee02323..39d024d 100644 --- a/backend/repository/recipientGroup.go +++ b/backend/repository/recipientGroup.go @@ -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", diff --git a/backend/seed/migrate.go b/backend/seed/migrate.go index 778166b..e87c028 100644 --- a/backend/seed/migrate.go +++ b/backend/seed/migrate.go @@ -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 diff --git a/backend/service/companyScimConfig.go b/backend/service/companyScimConfig.go new file mode 100644 index 0000000..7155a7d --- /dev/null +++ b/backend/service/companyScimConfig.go @@ -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 +} diff --git a/backend/service/recipientGroup.go b/backend/service/recipientGroup.go index 5b65264..ba126b7 100644 --- a/backend/service/recipientGroup.go +++ b/backend/service/recipientGroup.go @@ -243,6 +243,7 @@ func (r *RecipientGroup) GetAll( r.AuditLogNotAuthorized(ae) return result, errs.ErrAuthorizationFailed } + // get recipient groups result, err = r.RecipientGroupRepository.GetAll( ctx, diff --git a/backend/service/scim.go b/backend/service/scim.go new file mode 100644 index 0000000..03407ec --- /dev/null +++ b/backend/service/scim.go @@ -0,0 +1,2151 @@ +package service + +import ( + "context" + "fmt" + "strings" + + "github.com/go-errors/errors" + "github.com/google/uuid" + "github.com/oapi-codegen/nullable" + "github.com/phishingclub/phishingclub/errs" + "github.com/phishingclub/phishingclub/model" + "github.com/phishingclub/phishingclub/repository" + "github.com/phishingclub/phishingclub/vo" + "gorm.io/gorm" +) + +// scim v2 resource types +const ( + scimResourceTypeUser = "User" + scimResourceTypeGroup = "Group" +) + +// scim v2 schema URNs +const ( + scimSchemaUser = "urn:ietf:params:scim:schemas:core:2.0:User" + scimSchemaEnterpriseUser = "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User" + scimSchemaGroup = "urn:ietf:params:scim:schemas:core:2.0:Group" + scimSchemaCustomExtension = "urn:ietf:params:scim:schemas:extension:phishingclub:2.0:User" + scimSchemaListResponse = "urn:ietf:params:scim:api:messages:2.0:ListResponse" + scimSchemaError = "urn:ietf:params:scim:api:messages:2.0:Error" + scimSchemaPatchOp = "urn:ietf:params:scim:api:messages:2.0:PatchOp" +) + +// ScimUser is the SCIM v2 User resource representation used for both +// requests from the IdP and responses back to it. +type ScimUser struct { + // schemas must always be present in responses + Schemas []string `json:"schemas"` + ID string `json:"id,omitempty"` + UserName string `json:"userName"` + // name sub-object + Name *ScimName `json:"name,omitempty"` + // flat display name (used if name sub-object absent) + DisplayName string `json:"displayName,omitempty"` + // emails list — we treat the first primary (or first) as canonical + Emails []ScimEmail `json:"emails,omitempty"` + // phone numbers list + PhoneNumbers []ScimPhoneNumber `json:"phoneNumbers,omitempty"` + // enterprise extension fields (department, title/position) + // division is intentionally omitted — it is not stored + EnterpriseUser *ScimEnterpriseUser `json:"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User,omitempty"` + // addresses list — work address maps to city/country + Addresses []ScimAddress `json:"addresses,omitempty"` + // active flag — false means the account should be deprovisioned + Active bool `json:"active"` + // meta sub-object for responses + Meta *ScimMeta `json:"meta,omitempty"` + // externalId from IdP (stored in extra_identifier) + ExternalID string `json:"externalId,omitempty"` + // custom extension — misc/notes field + CustomExtension *ScimCustomExtension `json:"urn:ietf:params:scim:schemas:extension:phishingclub:2.0:User,omitempty"` + // groups the user is a member of — populated on responses, consumed on writes + Groups []ScimUserGroup `json:"groups,omitempty"` +} + +// ScimUserGroup is an entry in the groups array on a ScimUser resource. +// value is the group ID, display is the group display name. +type ScimUserGroup struct { + Value string `json:"value"` + Display string `json:"display,omitempty"` + Ref string `json:"$ref,omitempty"` +} + +// ScimName holds the structured name sub-object +type ScimName struct { + Formatted string `json:"formatted,omitempty"` + GivenName string `json:"givenName,omitempty"` + FamilyName string `json:"familyName,omitempty"` +} + +// ScimEmail is a single email entry in the emails array +type ScimEmail struct { + Value string `json:"value"` + Type string `json:"type,omitempty"` + Primary bool `json:"primary,omitempty"` +} + +// ScimPhoneNumber is a single phone number entry +type ScimPhoneNumber struct { + Value string `json:"value"` + Type string `json:"type,omitempty"` + Primary bool `json:"primary,omitempty"` +} + +// ScimAddress is a single address entry in the addresses array +type ScimAddress struct { + Type string `json:"type,omitempty"` + Locality string `json:"locality,omitempty"` // maps to city + Country string `json:"country,omitempty"` // maps to country (ISO 3166-1) + Primary bool `json:"primary,omitempty"` + Formatted string `json:"formatted,omitempty"` +} + +// ScimEnterpriseUser holds enterprise extension fields +type ScimEnterpriseUser struct { + Department string `json:"department,omitempty"` + Title string `json:"title,omitempty"` +} + +// ScimCustomExtension holds fields that have no standard SCIM home +type ScimCustomExtension struct { + // Misc maps to recipient.misc — free-form notes + Misc string `json:"misc,omitempty"` +} + +// ScimMeta is the meta sub-object returned in responses +type ScimMeta struct { + ResourceType string `json:"resourceType"` + Location string `json:"location,omitempty"` +} + +// ScimListResponse is the SCIM v2 ListResponse envelope +type ScimListResponse struct { + Schemas []string `json:"schemas"` + TotalResults int `json:"totalResults"` + StartIndex int `json:"startIndex"` + ItemsPerPage int `json:"itemsPerPage"` + Resources []ScimUser `json:"Resources"` +} + +// ScimError is the SCIM v2 error response body +type ScimError struct { + Schemas []string `json:"schemas"` + Status int `json:"status"` + Detail string `json:"detail,omitempty"` + ScimType string `json:"scimType,omitempty"` +} + +// ScimPatchOp is the body of a PATCH request (RFC 7644 §3.5.2) +type ScimPatchOp struct { + Schemas []string `json:"schemas"` + Operations []ScimPatchOpItem `json:"Operations"` +} + +// ScimPatchOpItem is a single operation within a PatchOp +type ScimPatchOpItem struct { + Op string `json:"op"` + Path string `json:"path,omitempty"` + Value any `json:"value,omitempty"` +} + +// ScimServiceProviderConfig is the ServiceProviderConfig response (RFC 7643 §5) +type ScimServiceProviderConfig struct { + Schemas []string `json:"schemas"` + DocumentationURI string `json:"documentationUri,omitempty"` + Patch ScimSupportedFeature `json:"patch"` + Bulk ScimBulkFeature `json:"bulk"` + Filter ScimFilterFeature `json:"filter"` + ChangePassword ScimSupportedFeature `json:"changePassword"` + Sort ScimSupportedFeature `json:"sort"` + ETag ScimSupportedFeature `json:"etag"` + AuthenticationSchemes []ScimAuthScheme `json:"authenticationSchemes"` + Meta *ScimMeta `json:"meta,omitempty"` +} + +// ScimSupportedFeature indicates whether a feature is supported +type ScimSupportedFeature struct { + Supported bool `json:"supported"` +} + +// ScimBulkFeature describes bulk support +type ScimBulkFeature struct { + Supported bool `json:"supported"` + MaxOperations int `json:"maxOperations"` + MaxPayloadSize int `json:"maxPayloadSize"` +} + +// ScimFilterFeature describes filter support +type ScimFilterFeature struct { + Supported bool `json:"supported"` + MaxResults int `json:"maxResults"` +} + +// ScimAuthScheme describes a supported authentication scheme +type ScimAuthScheme struct { + Type string `json:"type"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + SpecURI string `json:"specUri,omitempty"` + DocumentationURI string `json:"documentationUri,omitempty"` + Primary bool `json:"primary,omitempty"` +} + +// ScimResourceType is an entry in /ResourceTypes +type ScimResourceType struct { + Schemas []string `json:"schemas"` + ID string `json:"id"` + Name string `json:"name"` + Endpoint string `json:"endpoint"` + Description string `json:"description,omitempty"` + Schema string `json:"schema"` + SchemaExtensions []ScimSchemaExtension `json:"schemaExtensions"` + Meta *ScimMeta `json:"meta,omitempty"` +} + +// ScimSchemaExtension is a schema extension reference within a ResourceType +type ScimSchemaExtension struct { + Schema string `json:"schema"` + Required bool `json:"required"` +} + +// ScimSchemaAttribute describes a single attribute within a Schema document +type ScimSchemaAttribute struct { + Name string `json:"name"` + Type string `json:"type"` + MultiValued bool `json:"multiValued"` + Description string `json:"description,omitempty"` + Required bool `json:"required"` + CaseExact bool `json:"caseExact"` + Mutability string `json:"mutability"` + Returned string `json:"returned"` + Uniqueness string `json:"uniqueness"` + SubAttributes []ScimSchemaAttribute `json:"subAttributes,omitempty"` + ReferenceTypes []string `json:"referenceTypes,omitempty"` + CanonicalValues []string `json:"canonicalValues,omitempty"` +} + +// ScimSchema is a single Schema document returned by /Schemas +type ScimSchema struct { + Schemas []string `json:"schemas"` + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Attributes []ScimSchemaAttribute `json:"attributes"` + Meta *ScimMeta `json:"meta,omitempty"` +} + +// ScimGroup is the SCIM v2 Group resource representation. +// the IdP is the source of truth — groups are created, updated and deleted +// directly via the /Groups endpoints. +type ScimGroup struct { + Schemas []string `json:"schemas"` + ID string `json:"id,omitempty"` + DisplayName string `json:"displayName"` + Members []ScimGroupMember `json:"members,omitempty"` + Meta *ScimMeta `json:"meta,omitempty"` +} + +// ScimGroupMember is a member reference within a ScimGroup +type ScimGroupMember struct { + Value string `json:"value"` + Display string `json:"display,omitempty"` + Ref string `json:"$ref,omitempty"` +} + +// ScimConfigResult is returned by VerifyAndLoadConfig to the controller layer +type ScimConfigResult struct { + Config *model.CompanyScimConfig + CompanyID *uuid.UUID +} + +// ScimGroupPatchOp is the body of a PATCH /Groups/:id request +type ScimGroupPatchOp struct { + Schemas []string `json:"schemas"` + Operations []ScimGroupPatchOpItem `json:"Operations"` +} + +// ScimGroupPatchOpItem is a single operation within a PATCH /Groups request. +// op is "add", "remove", or "replace". path is optional. +type ScimGroupPatchOpItem struct { + Op string `json:"op"` + Path string `json:"path,omitempty"` + Value any `json:"value,omitempty"` +} + +// GetSchemaByID returns a single schema document by its URN. +// returns the schema and true if found, nil and false otherwise. +func (s *Scim) GetSchemaByID(baseURL string, id string) (*ScimSchema, bool) { + for _, schema := range s.Schemas(baseURL) { + if schema.ID == id { + return &schema, true + } + } + return nil, false +} + +// GetResourceTypeByID returns a single resource type by its ID (e.g. "User" or "Group"). +// returns the resource type and true if found, nil and false otherwise. +func (s *Scim) GetResourceTypeByID(baseURL string, id string) (*ScimResourceType, bool) { + for _, rt := range s.ResourceTypes(baseURL) { + if rt.ID == id { + return &rt, true + } + } + return nil, false +} + +// Scim is the service that handles SCIM v2 protocol operations. +// it is called by the SCIM HTTP handler (controller/scim.go) after +// bearer-token authentication has already been verified. +type Scim struct { + Common + CompanyScimConfigRepository *repository.CompanyScimConfig + CompanyScimConfigService *CompanyScimConfig + RecipientRepository *repository.Recipient + RecipientGroupRepository *repository.RecipientGroup + CampaignRepository *repository.Campaign +} + +// ServiceProviderConfig returns the static service provider configuration +// document describing what this SCIM implementation supports. +func (s *Scim) ServiceProviderConfig(baseURL string) *ScimServiceProviderConfig { + return &ScimServiceProviderConfig{ + Schemas: []string{"urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"}, + Patch: ScimSupportedFeature{Supported: true}, + Bulk: ScimBulkFeature{Supported: false, MaxOperations: 0, MaxPayloadSize: 0}, + Filter: ScimFilterFeature{Supported: true, MaxResults: 200}, + ChangePassword: ScimSupportedFeature{Supported: false}, + Sort: ScimSupportedFeature{Supported: false}, + ETag: ScimSupportedFeature{Supported: false}, + AuthenticationSchemes: []ScimAuthScheme{ + { + Type: "oauthbearertoken", + Name: "OAuth Bearer Token", + Description: "authentication using a bearer token issued by this application", + Primary: true, + }, + }, + Meta: &ScimMeta{ + ResourceType: "ServiceProviderConfig", + Location: baseURL + "/ServiceProviderConfig", + }, + } +} + +// ResourceTypes returns the list of supported resource types +func (s *Scim) ResourceTypes(baseURL string) []ScimResourceType { + return []ScimResourceType{ + { + Schemas: []string{"urn:ietf:params:scim:schemas:core:2.0:ResourceType"}, + ID: "User", + Name: "User", + Endpoint: "/Users", + Description: "user accounts", + Schema: scimSchemaUser, + SchemaExtensions: []ScimSchemaExtension{ + {Schema: scimSchemaEnterpriseUser, Required: false}, + {Schema: scimSchemaCustomExtension, Required: false}, + }, + Meta: &ScimMeta{ + ResourceType: "ResourceType", + Location: baseURL + "/ResourceTypes/User", + }, + }, + { + Schemas: []string{"urn:ietf:params:scim:schemas:core:2.0:ResourceType"}, + ID: "Group", + Name: "Group", + Endpoint: "/Groups", + Description: "recipient groups", + Schema: scimSchemaGroup, + SchemaExtensions: []ScimSchemaExtension{}, + Meta: &ScimMeta{ + ResourceType: "ResourceType", + Location: baseURL + "/ResourceTypes/Group", + }, + }, + } +} + +// Schemas returns the hardcoded schema documents for all supported resource types. +// these are static — no database required. +func (s *Scim) Schemas(baseURL string) []ScimSchema { + return []ScimSchema{ + { + Schemas: []string{"urn:ietf:params:scim:schemas:core:2.0:Schema"}, + ID: scimSchemaUser, + Name: "User", + Description: "user account", + Attributes: []ScimSchemaAttribute{ + { + Name: "userName", Type: "string", MultiValued: false, + Description: "unique identifier for the user", + Required: true, CaseExact: true, + Mutability: "readWrite", Returned: "default", Uniqueness: "server", + }, + { + Name: "name", Type: "complex", MultiValued: false, + Description: "the components of the user's name", + Required: false, CaseExact: false, + Mutability: "readWrite", Returned: "default", Uniqueness: "none", + SubAttributes: []ScimSchemaAttribute{ + {Name: "givenName", Type: "string", MultiValued: false, Description: "first name", Required: false, CaseExact: false, Mutability: "readWrite", Returned: "default", Uniqueness: "none"}, + {Name: "familyName", Type: "string", MultiValued: false, Description: "last name", Required: false, CaseExact: false, Mutability: "readWrite", Returned: "default", Uniqueness: "none"}, + {Name: "formatted", Type: "string", MultiValued: false, Description: "full name", Required: false, CaseExact: false, Mutability: "readWrite", Returned: "default", Uniqueness: "none"}, + }, + }, + { + Name: "displayName", Type: "string", MultiValued: false, + Description: "display name of the user", + Required: false, CaseExact: false, + Mutability: "readWrite", Returned: "default", Uniqueness: "none", + }, + { + Name: "emails", Type: "complex", MultiValued: true, + Description: "email addresses for the user", + Required: false, CaseExact: false, + Mutability: "readWrite", Returned: "default", Uniqueness: "none", + SubAttributes: []ScimSchemaAttribute{ + {Name: "value", Type: "string", MultiValued: false, Description: "email address", Required: false, CaseExact: false, Mutability: "readWrite", Returned: "default", Uniqueness: "none"}, + {Name: "type", Type: "string", MultiValued: false, Description: "type of email address", Required: false, CaseExact: false, Mutability: "readWrite", Returned: "default", Uniqueness: "none", CanonicalValues: []string{"work", "home", "other"}}, + {Name: "primary", Type: "boolean", MultiValued: false, Description: "primary email indicator", Required: false, CaseExact: false, Mutability: "readWrite", Returned: "default", Uniqueness: "none"}, + }, + }, + { + Name: "phoneNumbers", Type: "complex", MultiValued: true, + Description: "phone numbers for the user", + Required: false, CaseExact: false, + Mutability: "readWrite", Returned: "default", Uniqueness: "none", + SubAttributes: []ScimSchemaAttribute{ + {Name: "value", Type: "string", MultiValued: false, Description: "phone number", Required: false, CaseExact: false, Mutability: "readWrite", Returned: "default", Uniqueness: "none"}, + {Name: "type", Type: "string", MultiValued: false, Description: "type of phone number", Required: false, CaseExact: false, Mutability: "readWrite", Returned: "default", Uniqueness: "none", CanonicalValues: []string{"work", "home", "mobile", "other"}}, + {Name: "primary", Type: "boolean", MultiValued: false, Description: "primary phone indicator", Required: false, CaseExact: false, Mutability: "readWrite", Returned: "default", Uniqueness: "none"}, + }, + }, + { + Name: "addresses", Type: "complex", MultiValued: true, + Description: "addresses for the user — work address maps to city and country fields", + Required: false, CaseExact: false, + Mutability: "readWrite", Returned: "default", Uniqueness: "none", + SubAttributes: []ScimSchemaAttribute{ + {Name: "type", Type: "string", MultiValued: false, Description: "type of address", Required: false, CaseExact: false, Mutability: "readWrite", Returned: "default", Uniqueness: "none", CanonicalValues: []string{"work", "home", "other"}}, + {Name: "locality", Type: "string", MultiValued: false, Description: "city", Required: false, CaseExact: false, Mutability: "readWrite", Returned: "default", Uniqueness: "none"}, + {Name: "country", Type: "string", MultiValued: false, Description: "country (ISO 3166-1 alpha-2 or full name)", Required: false, CaseExact: false, Mutability: "readWrite", Returned: "default", Uniqueness: "none"}, + {Name: "primary", Type: "boolean", MultiValued: false, Description: "primary address indicator", Required: false, CaseExact: false, Mutability: "readWrite", Returned: "default", Uniqueness: "none"}, + {Name: "formatted", Type: "string", MultiValued: false, Description: "full mailing address formatted for display", Required: false, CaseExact: false, Mutability: "readWrite", Returned: "default", Uniqueness: "none"}, + }, + }, + { + Name: "active", Type: "boolean", MultiValued: false, + Description: "administrative status of the user — false removes them from all groups", + Required: false, CaseExact: false, + Mutability: "readWrite", Returned: "default", Uniqueness: "none", + }, + { + Name: "externalId", Type: "string", MultiValued: false, + Description: "identifier from the provisioning client (stored as extraIdentifier — unique per company)", + Required: false, CaseExact: true, + Mutability: "readWrite", Returned: "default", Uniqueness: "server", + }, + { + Name: "groups", Type: "complex", MultiValued: true, + Description: "groups the user belongs to", + Required: false, CaseExact: false, + Mutability: "readOnly", Returned: "request", Uniqueness: "none", + SubAttributes: []ScimSchemaAttribute{ + {Name: "value", Type: "string", MultiValued: false, Description: "group ID", Required: false, CaseExact: false, Mutability: "readOnly", Returned: "default", Uniqueness: "none"}, + {Name: "display", Type: "string", MultiValued: false, Description: "display name of the group", Required: false, CaseExact: false, Mutability: "readOnly", Returned: "default", Uniqueness: "none"}, + {Name: "$ref", Type: "reference", MultiValued: false, Description: "URI of the group", Required: false, CaseExact: false, Mutability: "readOnly", Returned: "default", Uniqueness: "none"}, + }, + }, + }, + Meta: &ScimMeta{ + ResourceType: "Schema", + Location: baseURL + "/Schemas/" + scimSchemaUser, + }, + }, + { + Schemas: []string{"urn:ietf:params:scim:schemas:core:2.0:Schema"}, + ID: scimSchemaEnterpriseUser, + Name: "EnterpriseUser", + Description: "enterprise user extension attributes", + Attributes: []ScimSchemaAttribute{ + { + Name: "department", Type: "string", MultiValued: false, + Description: "department the user belongs to (stored as department)", + Required: false, CaseExact: false, + Mutability: "readWrite", Returned: "default", Uniqueness: "none", + }, + { + Name: "title", Type: "string", MultiValued: false, + Description: "job title / position (stored as position)", + Required: false, CaseExact: false, + Mutability: "readWrite", Returned: "default", Uniqueness: "none", + }, + { + Name: "manager", Type: "complex", MultiValued: false, + Description: "the user's manager — not stored, accepted and silently ignored", + Required: false, CaseExact: false, + Mutability: "readWrite", Returned: "default", Uniqueness: "none", + SubAttributes: []ScimSchemaAttribute{ + {Name: "value", Type: "string", MultiValued: false, Description: "manager user ID", Required: false, CaseExact: false, Mutability: "readWrite", Returned: "default", Uniqueness: "none"}, + {Name: "$ref", Type: "reference", MultiValued: false, Description: "URI of the manager", Required: false, CaseExact: false, Mutability: "readWrite", Returned: "default", Uniqueness: "none"}, + {Name: "displayName", Type: "string", MultiValued: false, Description: "display name of the manager", Required: false, CaseExact: false, Mutability: "readOnly", Returned: "default", Uniqueness: "none"}, + }, + }, + }, + Meta: &ScimMeta{ + ResourceType: "Schema", + Location: baseURL + "/Schemas/" + scimSchemaEnterpriseUser, + }, + }, + { + Schemas: []string{"urn:ietf:params:scim:schemas:core:2.0:Schema"}, + ID: scimSchemaCustomExtension, + Name: "PhishingClubUser", + Description: "phishingclub-specific user extension attributes", + Attributes: []ScimSchemaAttribute{ + { + Name: "misc", Type: "string", MultiValued: false, + Description: "free-form notes field (stored as misc)", + Required: false, CaseExact: false, + Mutability: "readWrite", Returned: "default", Uniqueness: "none", + }, + }, + Meta: &ScimMeta{ + ResourceType: "Schema", + Location: baseURL + "/Schemas/" + scimSchemaCustomExtension, + }, + }, + { + Schemas: []string{"urn:ietf:params:scim:schemas:core:2.0:Schema"}, + ID: scimSchemaGroup, + Name: "Group", + Description: "recipient group", + Attributes: []ScimSchemaAttribute{ + { + Name: "displayName", Type: "string", MultiValued: false, + Description: "name of the group (unique per company)", + Required: true, CaseExact: false, + Mutability: "readWrite", Returned: "default", Uniqueness: "server", + }, + { + Name: "members", Type: "complex", MultiValued: true, + Description: "members of the group", + Required: false, CaseExact: false, + Mutability: "readWrite", Returned: "default", Uniqueness: "none", + SubAttributes: []ScimSchemaAttribute{ + {Name: "value", Type: "string", MultiValued: false, Description: "recipient ID", Required: false, CaseExact: false, Mutability: "immutable", Returned: "default", Uniqueness: "none"}, + {Name: "display", Type: "string", MultiValued: false, Description: "display name of the member", Required: false, CaseExact: false, Mutability: "readOnly", Returned: "default", Uniqueness: "none"}, + {Name: "$ref", Type: "reference", MultiValued: false, Description: "URI of the member user resource", Required: false, CaseExact: false, Mutability: "readOnly", Returned: "default", Uniqueness: "none"}, + }, + }, + }, + Meta: &ScimMeta{ + ResourceType: "Schema", + Location: baseURL + "/Schemas/" + scimSchemaGroup, + }, + }, + } +} + +// ListGroupsRaw returns all recipient groups for this company wrapped in a +// spec-compliant ListResponse. the IdP owns group creation so all company +// groups are visible. supports startIndex and count query parameters. +func (s *Scim) ListGroupsRaw( + ctx context.Context, + companyID *uuid.UUID, + config *model.CompanyScimConfig, + baseURL string, + startIndex int, + count int, + filter string, + excludedAttributes string, +) (any, error) { + groups, err := s.RecipientGroupRepository.GetAllByCompanyID(ctx, companyID, &repository.RecipientGroupOption{ + WithRecipients: true, + }) + if err != nil { + s.Logger.Errorw("scim list groups: failed to list groups", "error", err) + return nil, errs.Wrap(err) + } + + excludeMembers := scimExcludesAttribute(excludedAttributes, "members") + + all := make([]ScimGroup, 0, len(groups)) + for _, g := range groups { + if filter != "" && !scimGroupFilterMatches(filter, g) { + continue + } + sg := recipientGroupToScimGroup(g, baseURL) + if excludeMembers { + sg.Members = nil + } + all = append(all, sg) + } + + total := len(all) + + // apply startIndex (1-based per rfc 7644 §3.4.2) + if startIndex < 1 { + startIndex = 1 + } + offset := startIndex - 1 + if offset > total { + offset = total + } + all = all[offset:] + + // apply count + if count > 0 && count < len(all) { + all = all[:count] + } + + return map[string]any{ + "schemas": []string{scimSchemaListResponse}, + "totalResults": total, + "startIndex": startIndex, + "itemsPerPage": len(all), + "Resources": all, + }, nil +} + +// GetGroup returns a single group by ID as a SCIM Group resource. +func (s *Scim) GetGroup( + ctx context.Context, + companyID *uuid.UUID, + config *model.CompanyScimConfig, + groupID *uuid.UUID, + baseURL string, +) (*ScimGroup, error) { + group, err := s.RecipientGroupRepository.GetByID(ctx, groupID, &repository.RecipientGroupOption{ + WithRecipients: true, + }) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errs.Wrap(gorm.ErrRecordNotFound) + } + s.Logger.Errorw("scim get group: failed to get group", "error", err) + return nil, errs.Wrap(err) + } + // ensure the group belongs to this company + gCompanyID, err := group.CompanyID.Get() + if err != nil || gCompanyID != *companyID { + return nil, errs.Wrap(gorm.ErrRecordNotFound) + } + g := recipientGroupToScimGroup(group, baseURL) + return &g, nil +} + +// CreateGroup provisions a new recipient group from a SCIM Group resource. +// the IdP is the source of truth — it chooses the display name and membership. +func (s *Scim) CreateGroup( + ctx context.Context, + companyID *uuid.UUID, + config *model.CompanyScimConfig, + req *ScimGroup, + baseURL string, +) (*ScimGroup, error) { + if strings.TrimSpace(req.DisplayName) == "" { + return nil, errs.NewSyntaxError(fmt.Errorf("displayName is required")) + } + + nameVO, err := vo.NewString127(req.DisplayName) + if err != nil { + return nil, errs.NewSyntaxError(fmt.Errorf("displayName too long")) + } + + rg := &model.RecipientGroup{ + Name: nullable.NewNullableWithValue(*nameVO), + CompanyID: nullable.NewNullableWithValue(*companyID), + } + groupID, err := s.RecipientGroupRepository.Insert(ctx, rg) + if err != nil { + if isScimUniqueConflict(err) { + return nil, errs.NewConflictError(fmt.Errorf("a group named %q already exists", req.DisplayName)) + } + s.Logger.Errorw("scim create group: failed to insert group", "error", err) + return nil, errs.Wrap(err) + } + + // add any members supplied in the create request + if len(req.Members) > 0 { + if err := s.applyGroupMembers(ctx, companyID, groupID, req.Members); err != nil { + return nil, err + } + } + + created, err := s.RecipientGroupRepository.GetByID(ctx, groupID, &repository.RecipientGroupOption{ + WithRecipients: true, + }) + if err != nil { + s.Logger.Errorw("scim create group: failed to reload group", "error", err) + return nil, errs.Wrap(err) + } + g := recipientGroupToScimGroup(created, baseURL) + return &g, nil +} + +// ReplaceGroup performs a full replacement (PUT) of an existing group. +// the display name is updated and membership is replaced wholesale. +func (s *Scim) ReplaceGroup( + ctx context.Context, + companyID *uuid.UUID, + config *model.CompanyScimConfig, + groupID *uuid.UUID, + req *ScimGroup, + baseURL string, +) (*ScimGroup, error) { + existing, err := s.RecipientGroupRepository.GetByID(ctx, groupID, &repository.RecipientGroupOption{ + WithRecipients: true, + }) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errs.Wrap(gorm.ErrRecordNotFound) + } + return nil, errs.Wrap(err) + } + // ensure the group belongs to this company + gCompanyID, compErr := existing.CompanyID.Get() + if compErr != nil || gCompanyID != *companyID { + return nil, errs.Wrap(gorm.ErrRecordNotFound) + } + + if strings.TrimSpace(req.DisplayName) != "" { + nameVO, err := vo.NewString127(req.DisplayName) + if err != nil { + return nil, errs.NewValidationError(fmt.Errorf("displayName too long")) + } + existing.Name = nullable.NewNullableWithValue(*nameVO) + if err := s.RecipientGroupRepository.UpdateByID(ctx, groupID, existing); err != nil { + s.Logger.Errorw("scim replace group: failed to update group name", "error", err) + return nil, errs.Wrap(err) + } + } + + // replace membership: remove everyone then add the supplied list + if err := s.replaceGroupMembers(ctx, companyID, groupID, existing.Recipients, req.Members); err != nil { + return nil, err + } + + updated, err := s.RecipientGroupRepository.GetByID(ctx, groupID, &repository.RecipientGroupOption{ + WithRecipients: true, + }) + if err != nil { + return nil, errs.Wrap(err) + } + g := recipientGroupToScimGroup(updated, baseURL) + return &g, nil +} + +// PatchGroup applies a SCIM PatchOp to an existing group. +// supported operations: replace displayName, add/remove members. +func (s *Scim) PatchGroup( + ctx context.Context, + companyID *uuid.UUID, + config *model.CompanyScimConfig, + groupID *uuid.UUID, + patch *ScimGroupPatchOp, + baseURL string, +) (*ScimGroup, error) { + existing, err := s.RecipientGroupRepository.GetByID(ctx, groupID, &repository.RecipientGroupOption{ + WithRecipients: true, + }) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errs.Wrap(gorm.ErrRecordNotFound) + } + return nil, errs.Wrap(err) + } + // ensure the group belongs to this company + gCompanyID, compErr := existing.CompanyID.Get() + if compErr != nil || gCompanyID != *companyID { + return nil, errs.Wrap(gorm.ErrRecordNotFound) + } + + for _, op := range patch.Operations { + switch strings.ToLower(op.Op) { + case "replace": + if err := s.applyGroupPatchReplace(ctx, companyID, existing, groupID, op); err != nil { + return nil, err + } + case "add": + members := groupMembersFromPatchValue(op.Value) + if err := s.applyGroupMembers(ctx, companyID, groupID, members); err != nil { + return nil, err + } + case "remove": + // path can be "members" (value array) or "members[value eq \"\"]" (filter form) + members := groupMembersFromPatchPath(op.Path, op.Value) + if len(members) > 0 { + if err := s.removeGroupMembers(ctx, companyID, groupID, members); err != nil { + return nil, err + } + } + } + } + + updated, err := s.RecipientGroupRepository.GetByID(ctx, groupID, &repository.RecipientGroupOption{ + WithRecipients: true, + }) + if err != nil { + return nil, errs.Wrap(err) + } + g := recipientGroupToScimGroup(updated, baseURL) + return &g, nil +} + +// DeleteGroup removes a recipient group provisioned via SCIM. +// members are removed from the group but not deleted from the system. +func (s *Scim) DeleteGroup( + ctx context.Context, + companyID *uuid.UUID, + config *model.CompanyScimConfig, + groupID *uuid.UUID, +) error { + existing, err := s.RecipientGroupRepository.GetByID(ctx, groupID, &repository.RecipientGroupOption{}) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errs.Wrap(gorm.ErrRecordNotFound) + } + return errs.Wrap(err) + } + // ensure the group belongs to this company + gCompanyID, compErr := existing.CompanyID.Get() + if compErr != nil || gCompanyID != *companyID { + return errs.Wrap(gorm.ErrRecordNotFound) + } + // unlink the group from any campaigns before deleting it so foreign key + // constraints on campaign_recipient_groups do not block the delete + if err := s.CampaignRepository.RemoveCampaignRecipientGroupByGroupID(ctx, groupID); err != nil { + s.Logger.Errorw("scim delete group: failed to remove group from campaigns", "error", err) + return errs.Wrap(err) + } + if err := s.RecipientGroupRepository.DeleteByID(ctx, groupID); err != nil { + s.Logger.Errorw("scim delete group: failed to delete group", "error", err) + return errs.Wrap(err) + } + return nil +} + +// recipientGroupToScimGroup maps a model.RecipientGroup to a ScimGroup +func recipientGroupToScimGroup(group *model.RecipientGroup, baseURL string) ScimGroup { + id := "" + if gid, err := group.ID.Get(); err == nil { + id = gid.String() + } + name := "" + if n, err := group.Name.Get(); err == nil { + name = n.String() + } + + members := make([]ScimGroupMember, 0, len(group.Recipients)) + for _, r := range group.Recipients { + rid, err := r.ID.Get() + if err != nil { + continue + } + display := "" + if fn, err := r.FirstName.Get(); err == nil { + display = fn.String() + } + if ln, err := r.LastName.Get(); err == nil { + if display != "" { + display += " " + } + display += ln.String() + } + if display == "" { + if e, err := r.Email.Get(); err == nil { + display = e.String() + } + } + ref := "" + if baseURL != "" { + ref = baseURL + "/Users/" + rid.String() + } + members = append(members, ScimGroupMember{ + Value: rid.String(), + Display: display, + Ref: ref, + }) + } + + g := ScimGroup{ + Schemas: []string{scimSchemaGroup}, + ID: id, + DisplayName: name, + Members: members, + } + if baseURL != "" && id != "" { + g.Meta = &ScimMeta{ + ResourceType: scimResourceTypeGroup, + Location: baseURL + "/Groups/" + id, + } + } + return g +} + +// ListUsers returns a SCIM ListResponse of all recipients belonging to this +// company. all provisioned users are visible regardless of group membership. +// supports filter, startIndex, count, sortBy and sortOrder query parameters. +func (s *Scim) ListUsers( + ctx context.Context, + companyID *uuid.UUID, + config *model.CompanyScimConfig, + baseURL string, + filter string, + startIndex int, + count int, + sortBy string, + sortOrder string, +) (*ScimListResponse, error) { + recipientResult, err := s.RecipientRepository.GetAllByCompanyID(ctx, companyID, &repository.RecipientOption{}) + if err != nil { + s.Logger.Errorw("scim list users: failed to list recipients", "error", err) + return nil, errs.Wrap(err) + } + + // build the full filtered list first so totalResults is accurate + all := make([]ScimUser, 0, len(recipientResult.Rows)) + for _, r := range recipientResult.Rows { + u := recipientToScimUser(r, baseURL) + if filter != "" && !scimFilterMatchesUser(filter, u) { + continue + } + all = append(all, u) + } + + // sort if requested + if sortBy != "" { + scimSortUsers(all, sortBy, sortOrder) + } + + total := len(all) + + // apply startIndex (1-based per rfc 7644 §3.4.2) + if startIndex < 1 { + startIndex = 1 + } + offset := startIndex - 1 + if offset > total { + offset = total + } + all = all[offset:] + + // apply count + if count > 0 && count < len(all) { + all = all[:count] + } + + return &ScimListResponse{ + Schemas: []string{scimSchemaListResponse}, + TotalResults: total, + StartIndex: startIndex, + ItemsPerPage: len(all), + Resources: all, + }, nil +} + +// GetUser returns a single SCIM User resource by recipient ID. +func (s *Scim) GetUser( + ctx context.Context, + companyID *uuid.UUID, + config *model.CompanyScimConfig, + recipientID *uuid.UUID, + baseURL string, +) (*ScimUser, error) { + recipient, err := s.RecipientRepository.GetByID(ctx, recipientID, &repository.RecipientOption{}) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errs.Wrap(gorm.ErrRecordNotFound) + } + s.Logger.Errorw("scim get user: failed to get recipient", "error", err, "recipientID", recipientID.String()) + return nil, errs.Wrap(err) + } + // ensure the recipient belongs to this company + rCompanyID, compErr := recipient.CompanyID.Get() + if compErr != nil || rCompanyID != *companyID { + return nil, errs.Wrap(gorm.ErrRecordNotFound) + } + u := recipientToScimUser(recipient, baseURL) + return &u, nil +} + +// CreateUser provisions a new recipient from a SCIM User resource. +// duplicate userName within the same company returns a 409 ConflictError. +// group membership from the groups array is applied after the recipient is persisted. +func (s *Scim) CreateUser( + ctx context.Context, + companyID *uuid.UUID, + config *model.CompanyScimConfig, + scimUser *ScimUser, + baseURL string, +) (*ScimUser, error) { + email, err := canonicalEmail(scimUser) + if err != nil { + return nil, errs.NewValidationError(err) + } + emailVO, err := vo.NewEmail(email) + if err != nil { + return nil, errs.NewValidationError(fmt.Errorf("invalid email %q: %w", email, err)) + } + + // dedup lookup uses lowercase so matching is case-insensitive + emailLower, _ := canonicalEmailLower(scimUser) + emailLowerVO, lookupErr := vo.NewEmail(emailLower) + if lookupErr != nil { + emailLowerVO = emailVO + } + + // reject duplicate userName — rfc 7644 requires 409 for uniqueness conflicts + existingByEmail, err := s.RecipientRepository.GetByEmailAndCompanyID(ctx, emailLowerVO, companyID) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + s.Logger.Errorw("scim create user: lookup by email failed", "error", err, "email", email) + return nil, errs.Wrap(err) + } + if existingByEmail != nil { + return nil, errs.NewConflictError(fmt.Errorf("a user with userName %q already exists", scimUserNameFrom(scimUser))) + } + + // create new recipient + var recipientID *uuid.UUID + r := scimUserToRecipient(scimUser, companyID) + id, err := s.RecipientRepository.Insert(ctx, r) + if err != nil { + if isScimUniqueConflict(err) { + return nil, errs.NewConflictError(fmt.Errorf("a user with userName %q already exists", scimUserNameFrom(scimUser))) + } + s.Logger.Errorw("scim create user: failed to insert recipient", "error", err) + return nil, errs.Wrap(err) + } + recipientID = id + + // add to any groups specified in the request + if err := s.syncUserGroupMembership(ctx, companyID, recipientID, scimUser.Groups); err != nil { + s.Logger.Warnw("scim create user: failed to sync group membership", "error", err) + } + + created, err := s.RecipientRepository.GetByID(ctx, recipientID, &repository.RecipientOption{}) + if err != nil { + s.Logger.Errorw("scim create user: failed to reload recipient", "error", err) + return nil, errs.Wrap(err) + } + u := recipientToScimUser(created, baseURL) + return &u, nil +} + +// ReplaceUser performs a full replacement (PUT) of an existing recipient from +// a SCIM User resource. group membership is replaced if a groups array is present. +func (s *Scim) ReplaceUser( + ctx context.Context, + companyID *uuid.UUID, + config *model.CompanyScimConfig, + recipientID *uuid.UUID, + scimUser *ScimUser, + baseURL string, +) (*ScimUser, error) { + existing, err := s.RecipientRepository.GetByID(ctx, recipientID, &repository.RecipientOption{}) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errs.Wrap(gorm.ErrRecordNotFound) + } + return nil, errs.Wrap(err) + } + // ensure the recipient belongs to this company + rCompanyID, compErr := existing.CompanyID.Get() + if compErr != nil || rCompanyID != *companyID { + return nil, errs.Wrap(gorm.ErrRecordNotFound) + } + if err := s.applyScimUserToRecipient(ctx, existing, scimUser); err != nil { + return nil, err + } + if len(scimUser.Groups) > 0 { + if err := s.syncUserGroupMembership(ctx, companyID, recipientID, scimUser.Groups); err != nil { + s.Logger.Warnw("scim replace user: failed to sync group membership", "error", err) + } + } + updated, err := s.RecipientRepository.GetByID(ctx, recipientID, &repository.RecipientOption{}) + if err != nil { + return nil, errs.Wrap(err) + } + u := recipientToScimUser(updated, baseURL) + return &u, nil +} + +// PatchUser applies a SCIM PatchOp to an existing recipient. +// supported operations: replace on top-level attributes and the active flag. +// active=false removes the recipient from all scim-managed groups (safe deprovision). +func (s *Scim) PatchUser( + ctx context.Context, + companyID *uuid.UUID, + config *model.CompanyScimConfig, + recipientID *uuid.UUID, + patch *ScimPatchOp, + baseURL string, +) (*ScimUser, error) { + existing, err := s.RecipientRepository.GetByID(ctx, recipientID, &repository.RecipientOption{}) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errs.Wrap(gorm.ErrRecordNotFound) + } + return nil, errs.Wrap(err) + } + // ensure the recipient belongs to this company + rCompanyID, compErr := existing.CompanyID.Get() + if compErr != nil || rCompanyID != *companyID { + return nil, errs.Wrap(gorm.ErrRecordNotFound) + } + + for _, op := range patch.Operations { + switch strings.ToLower(op.Op) { + case "replace", "add": + deactivated, err := s.applyPatchOperation(ctx, existing, config, recipientID, op) + if err != nil { + return nil, err + } + // active=false triggers a hard-delete; nothing more to do + if deactivated { + return nil, errs.Wrap(gorm.ErrRecordNotFound) + } + case "remove": + // remove op on "active" means deactivate — hard-delete the recipient + if strings.EqualFold(op.Path, "active") { + if err := s.RecipientGroupRepository.RemoveRecipientByIDFromAllGroups(ctx, recipientID); err != nil { + s.Logger.Warnw("scim patch remove active: failed to remove from groups", "error", err) + } + if err := s.RecipientRepository.DeleteByID(ctx, recipientID); err != nil { + return nil, errs.Wrap(err) + } + return nil, errs.Wrap(gorm.ErrRecordNotFound) + } + } + } + + updated, err := s.RecipientRepository.GetByID(ctx, recipientID, &repository.RecipientOption{}) + if err != nil { + return nil, errs.Wrap(err) + } + u := recipientToScimUser(updated, baseURL) + return &u, nil +} + +// DeprovisionUser hard-deletes a recipient provisioned via SCIM. +// a subsequent GET returns 404, satisfying the validator expectation that +// a deleted user is no longer accessible. +func (s *Scim) DeprovisionUser( + ctx context.Context, + companyID *uuid.UUID, + config *model.CompanyScimConfig, + recipientID *uuid.UUID, +) error { + existing, err := s.RecipientRepository.GetByID(ctx, recipientID, &repository.RecipientOption{}) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errs.Wrap(gorm.ErrRecordNotFound) + } + return errs.Wrap(err) + } + // ensure the recipient belongs to this company + rCompanyID, compErr := existing.CompanyID.Get() + if compErr != nil || rCompanyID != *companyID { + return errs.Wrap(gorm.ErrRecordNotFound) + } + // remove from all groups first to avoid orphan join rows + if err := s.RecipientGroupRepository.RemoveRecipientByIDFromAllGroups(ctx, recipientID); err != nil { + s.Logger.Warnw("scim deprovision user: failed to remove from groups", "error", err) + } + return s.RecipientRepository.DeleteByID(ctx, recipientID) +} + +// VerifyAndLoadConfig authenticates the bearer token against the stored hash +// for the given company and returns the active SCIM config. +// returns (config, authed, error). authed is false when the token is wrong. +func (s *Scim) VerifyAndLoadConfig( + ctx context.Context, + companyID *uuid.UUID, + plainToken string, +) (*model.CompanyScimConfig, bool, error) { + ok, config, err := s.CompanyScimConfigService.VerifyToken(ctx, companyID, plainToken) + if err != nil { + return nil, false, err + } + if !ok { + return nil, false, nil + } + if config == nil || !config.Enabled { + return config, false, nil + } + return config, true, nil +} + +// UpdateLastSync stamps the last sync time for the config +func (s *Scim) UpdateLastSync(ctx context.Context, config *model.CompanyScimConfig) { + id, err := config.ID.Get() + if err != nil { + return + } + if err := s.CompanyScimConfigRepository.UpdateLastSyncAt(ctx, &id); err != nil { + s.Logger.Warnw("scim: failed to update last_sync_at", "error", err, "configID", id.String()) + } +} + +// ── helpers ─────────────────────────────────────────────────────────────────── + +// groupsForRecipient returns the ScimUserGroup list for a recipient by scanning +// all company groups for membership. +func (s *Scim) groupsForRecipient( + ctx context.Context, + companyID *uuid.UUID, + recipientID *uuid.UUID, + baseURL string, +) []ScimUserGroup { + groups, err := s.RecipientGroupRepository.GetAllByCompanyID(ctx, companyID, &repository.RecipientGroupOption{ + WithRecipients: true, + }) + if err != nil { + s.Logger.Warnw("scim: failed to load groups for recipient membership", "error", err) + return nil + } + var result []ScimUserGroup + for _, g := range groups { + for _, r := range g.Recipients { + rid, ridErr := r.ID.Get() + if ridErr != nil { + continue + } + if rid == *recipientID { + gid, gidErr := g.ID.Get() + if gidErr != nil { + continue + } + name := "" + if n, err := g.Name.Get(); err == nil { + name = n.String() + } + ref := "" + if baseURL != "" { + ref = baseURL + "/Groups/" + gid.String() + } + result = append(result, ScimUserGroup{ + Value: gid.String(), + Display: name, + Ref: ref, + }) + break + } + } + } + return result +} + +// syncUserGroupMembership adds the recipient to all groups referenced in the +// groups array. groups that do not belong to this company are silently skipped. +func (s *Scim) syncUserGroupMembership( + ctx context.Context, + companyID *uuid.UUID, + recipientID *uuid.UUID, + groups []ScimUserGroup, +) error { + for _, g := range groups { + if g.Value == "" { + continue + } + gid, err := uuid.Parse(g.Value) + if err != nil { + continue + } + // verify the group belongs to this company before adding + group, err := s.RecipientGroupRepository.GetByID(ctx, &gid, &repository.RecipientGroupOption{}) + if err != nil { + s.Logger.Warnw("scim sync group membership: group not found, skipping", "groupID", gid.String()) + continue + } + gCompanyID, compErr := group.CompanyID.Get() + if compErr != nil || gCompanyID != *companyID { + s.Logger.Warnw("scim sync group membership: group does not belong to company, skipping", "groupID", gid.String()) + continue + } + if err := s.RecipientGroupRepository.AddRecipients(ctx, &gid, []*uuid.UUID{recipientID}); err != nil { + return errs.Wrap(err) + } + } + return nil +} + +// applyGroupMembers adds a list of SCIM member entries to a group. +// members referencing recipients that do not belong to companyID are skipped. +func (s *Scim) applyGroupMembers( + ctx context.Context, + companyID *uuid.UUID, + groupID *uuid.UUID, + members []ScimGroupMember, +) error { + for _, m := range members { + if m.Value == "" { + continue + } + rid, err := uuid.Parse(m.Value) + if err != nil { + continue + } + // verify the recipient belongs to the same company as the SCIM token + recipient, err := s.RecipientRepository.GetByID(ctx, &rid, &repository.RecipientOption{}) + if err != nil { + s.Logger.Warnw("scim apply group members: recipient not found, skipping", "recipientID", rid.String()) + continue + } + rCompanyID, compErr := recipient.CompanyID.Get() + if compErr != nil || rCompanyID != *companyID { + s.Logger.Warnw("scim apply group members: recipient does not belong to company, skipping", "recipientID", rid.String()) + continue + } + if err := s.RecipientGroupRepository.AddRecipients(ctx, groupID, []*uuid.UUID{&rid}); err != nil { + s.Logger.Errorw("scim apply group members: failed to add recipient", "error", err, "recipientID", rid.String()) + return errs.Wrap(err) + } + } + return nil +} + +// removeGroupMembers removes a list of SCIM member entries from a group. +// recipients that do not belong to companyID are skipped. +func (s *Scim) removeGroupMembers( + ctx context.Context, + companyID *uuid.UUID, + groupID *uuid.UUID, + members []ScimGroupMember, +) error { + ids := make([]*uuid.UUID, 0, len(members)) + for _, m := range members { + if m.Value == "" { + continue + } + rid, err := uuid.Parse(m.Value) + if err != nil { + continue + } + // verify the recipient belongs to the same company as the SCIM token + recipient, err := s.RecipientRepository.GetByID(ctx, &rid, &repository.RecipientOption{}) + if err != nil { + s.Logger.Warnw("scim remove group members: recipient not found, skipping", "recipientID", rid.String()) + continue + } + rCompanyID, compErr := recipient.CompanyID.Get() + if compErr != nil || rCompanyID != *companyID { + s.Logger.Warnw("scim remove group members: recipient does not belong to company, skipping", "recipientID", rid.String()) + continue + } + idCopy := rid + ids = append(ids, &idCopy) + } + if len(ids) == 0 { + return nil + } + return s.RecipientGroupRepository.RemoveRecipients(ctx, groupID, ids) +} + +// replaceGroupMembers removes all existing members and adds the new set. +func (s *Scim) replaceGroupMembers( + ctx context.Context, + companyID *uuid.UUID, + groupID *uuid.UUID, + existing []*model.Recipient, + incoming []ScimGroupMember, +) error { + // remove current members + currentIDs := make([]*uuid.UUID, 0, len(existing)) + for _, r := range existing { + rid, err := r.ID.Get() + if err != nil { + continue + } + idCopy := rid + currentIDs = append(currentIDs, &idCopy) + } + if len(currentIDs) > 0 { + // existing members were already verified when they were added; remove directly + if err := s.RecipientGroupRepository.RemoveRecipients(ctx, groupID, currentIDs); err != nil { + return errs.Wrap(err) + } + } + return s.applyGroupMembers(ctx, companyID, groupID, incoming) +} + +// applyGroupPatchReplace handles a replace operation on a group patch. +func (s *Scim) applyGroupPatchReplace( + ctx context.Context, + companyID *uuid.UUID, + existing *model.RecipientGroup, + groupID *uuid.UUID, + op ScimGroupPatchOpItem, +) error { + path := strings.ToLower(op.Path) + switch path { + case "displayname": + name := stringFromPatchValue(op.Value) + if name == "" { + return nil + } + nameVO, err := vo.NewString127(name) + if err != nil { + return errs.NewValidationError(fmt.Errorf("displayName too long")) + } + existing.Name = nullable.NewNullableWithValue(*nameVO) + if err := s.RecipientGroupRepository.UpdateByID(ctx, groupID, existing); err != nil { + return errs.Wrap(err) + } + case "members": + members := groupMembersFromPatchValue(op.Value) + if err := s.replaceGroupMembers(ctx, companyID, groupID, existing.Recipients, members); err != nil { + return err + } + case "": + // no path — value is a map of attributes + if m, ok := op.Value.(map[string]any); ok { + if dn, ok := m["displayName"].(string); ok && dn != "" { + nameVO, err := vo.NewString127(dn) + if err != nil { + return errs.NewValidationError(fmt.Errorf("displayName too long")) + } + existing.Name = nullable.NewNullableWithValue(*nameVO) + if err := s.RecipientGroupRepository.UpdateByID(ctx, groupID, existing); err != nil { + return errs.Wrap(err) + } + } + } + } + return nil +} + +// groupMembersFromPatchValue coerces a PatchOp value to []ScimGroupMember. +// the IdP may send either a slice of objects or a single object. +func groupMembersFromPatchValue(v any) []ScimGroupMember { + if v == nil { + return nil + } + // slice of maps + if items, ok := v.([]any); ok { + result := make([]ScimGroupMember, 0, len(items)) + for _, item := range items { + if m, ok := item.(map[string]any); ok { + val, _ := m["value"].(string) + display, _ := m["display"].(string) + result = append(result, ScimGroupMember{Value: val, Display: display}) + } + } + return result + } + // single map + if m, ok := v.(map[string]any); ok { + val, _ := m["value"].(string) + display, _ := m["display"].(string) + return []ScimGroupMember{{Value: val, Display: display}} + } + return nil +} + +// groupMembersFromPatchPath handles both the plain value array form and the +// filter path form "members[value eq \"\"]" used by some IdPs for remove ops. +func groupMembersFromPatchPath(path string, v any) []ScimGroupMember { + // filter path form: members[value eq ""] + lower := strings.ToLower(strings.TrimSpace(path)) + const filterPrefix = "members[value eq \"" + if strings.HasPrefix(lower, filterPrefix) { + inner := path[len(filterPrefix):] + inner = strings.TrimSuffix(inner, "\"]") + inner = strings.TrimSuffix(inner, "\"]") + if inner != "" { + return []ScimGroupMember{{Value: inner}} + } + } + // plain "members" path — fall back to parsing the value array + return groupMembersFromPatchValue(v) +} + +// buildGroupsByRecipient builds a map from recipient UUID to the list of +// ScimUserGroup entries the recipient belongs to. +func buildGroupsByRecipient(groups []*model.RecipientGroup) map[uuid.UUID][]ScimUserGroup { + result := make(map[uuid.UUID][]ScimUserGroup) + for _, g := range groups { + gid, err := g.ID.Get() + if err != nil { + continue + } + name := "" + if n, err := g.Name.Get(); err == nil { + name = n.String() + } + for _, r := range g.Recipients { + rid, err := r.ID.Get() + if err != nil { + continue + } + result[rid] = append(result[rid], ScimUserGroup{ + Value: gid.String(), + Display: name, + }) + } + } + return result +} + +// applyScimUserToRecipient writes the mutable SCIM User fields onto an existing +// recipient model and persists the changes via the repository. +func (s *Scim) applyScimUserToRecipient( + ctx context.Context, + existing *model.Recipient, + scimUser *ScimUser, +) error { + // always update the stored scim userName so it round-trips + existing.ScimUserName.Set(*vo.NewOptionalString127Must(truncate(scimUserNameFrom(scimUser), 127))) + // email + if email, err := canonicalEmail(scimUser); err == nil && email != "" { + if ev, err := vo.NewEmail(email); err == nil { + existing.Email.Set(*ev) + } + } + // first name + if fn := firstNameFrom(scimUser); fn != "" { + existing.FirstName.Set(*vo.NewOptionalString127Must(truncate(fn, 127))) + } + // last name + if ln := lastNameFrom(scimUser); ln != "" { + existing.LastName.Set(*vo.NewOptionalString127Must(truncate(ln, 127))) + } + // phone + if phone := primaryPhoneFrom(scimUser); phone != "" { + existing.Phone.Set(*vo.NewOptionalString127Must(truncate(phone, 127))) + } + // department and title from enterprise extension + if scimUser.EnterpriseUser != nil { + if scimUser.EnterpriseUser.Department != "" { + existing.Department.Set(*vo.NewOptionalString127Must(truncate(scimUser.EnterpriseUser.Department, 127))) + } + if scimUser.EnterpriseUser.Title != "" { + existing.Position.Set(*vo.NewOptionalString127Must(truncate(scimUser.EnterpriseUser.Title, 127))) + } + } + // addresses — city and country from primary/work address + if len(scimUser.Addresses) > 0 { + city, country := primaryAddressFrom(scimUser) + if city != "" { + existing.City.Set(*vo.NewOptionalString127Must(truncate(city, 127))) + } + if country != "" { + existing.Country.Set(*vo.NewOptionalString127Must(truncate(country, 127))) + } + } + // externalId -> extra_identifier + if scimUser.ExternalID != "" { + existing.ExtraIdentifier.Set(*vo.NewOptionalString127Must(truncate(scimUser.ExternalID, 127))) + } + // misc from custom extension + if scimUser.CustomExtension != nil && scimUser.CustomExtension.Misc != "" { + existing.Misc.Set(*vo.NewOptionalString127Must(truncate(scimUser.CustomExtension.Misc, 127))) + } + + id := existing.ID.MustGet() + if err := s.RecipientRepository.UpdateByID(ctx, &id, existing); err != nil { + s.Logger.Errorw("scim apply user: failed to update recipient", "error", err) + return errs.Wrap(err) + } + return nil +} + +// primaryAddressFrom extracts city and country from the addresses array. +// prefers primary=true, then type="work", then the first entry. +func primaryAddressFrom(u *ScimUser) (city, country string) { + var best *ScimAddress + for i := range u.Addresses { + a := &u.Addresses[i] + if a.Primary { + best = a + break + } + } + if best == nil { + for i := range u.Addresses { + a := &u.Addresses[i] + if strings.EqualFold(a.Type, "work") { + best = a + break + } + } + } + if best == nil && len(u.Addresses) > 0 { + best = &u.Addresses[0] + } + if best == nil { + return "", "" + } + return best.Locality, best.Country +} + +// applyPatchOperation handles a single replace/add PatchOp operation on a recipient. +// returns (deactivated bool, error) — deactivated is true when active=false triggers +// a hard-delete so the caller can short-circuit without trying to reload the recipient. +func (s *Scim) applyPatchOperation( + ctx context.Context, + existing *model.Recipient, + config *model.CompanyScimConfig, + recipientID *uuid.UUID, + op ScimPatchOpItem, +) (bool, error) { + path := strings.ToLower(op.Path) + + // handle active flag — false means hard-delete the recipient + if path == "active" { + active := boolFromPatchValue(op.Value) + if !active { + if err := s.RecipientGroupRepository.RemoveRecipientByIDFromAllGroups(ctx, recipientID); err != nil { + s.Logger.Warnw("scim patch active=false: failed to remove from groups", "error", err) + } + return true, s.RecipientRepository.DeleteByID(ctx, recipientID) + } + // active=true is a no-op; group assignment is managed via /Groups + return false, nil + } + + // for no path, value is expected to be a map of attribute → value + if op.Path == "" { + if m, ok := op.Value.(map[string]any); ok { + // check for active=false inside the map before applying other fields + if rawActive, ok := m["active"]; ok && !boolFromPatchValue(rawActive) { + if err := s.RecipientGroupRepository.RemoveRecipientByIDFromAllGroups(ctx, recipientID); err != nil { + s.Logger.Warnw("scim patch active=false (map): failed to remove from groups", "error", err) + } + return true, s.RecipientRepository.DeleteByID(ctx, recipientID) + } + if err := s.applyAttributeMap(ctx, existing, config, recipientID, m); err != nil { + return false, err + } + } + id := existing.ID.MustGet() + if err := s.RecipientRepository.UpdateByID(ctx, &id, existing); err != nil { + return false, errs.Wrap(err) + } + return false, nil + } + + // single attribute path — only apply values that map to our data model + strVal := stringFromPatchValue(op.Value) + switch path { + case "username": + existing.ScimUserName.Set(*vo.NewOptionalString127Must(truncate(strVal, 127))) + case "emails[type eq \"work\"].value", "emails": + if ev, err := vo.NewEmail(strVal); err == nil { + existing.Email.Set(*ev) + } + case "name.givenname": + existing.FirstName.Set(*vo.NewOptionalString127Must(truncate(strVal, 127))) + case "name.familyname": + existing.LastName.Set(*vo.NewOptionalString127Must(truncate(strVal, 127))) + case "name.formatted": + // split formatted into first/last only when individual names are not already set + parts := strings.SplitN(strVal, " ", 2) + existingFirst := "" + if v, err := existing.FirstName.Get(); err == nil { + existingFirst = v.String() + } + existingLast := "" + if v, err := existing.LastName.Get(); err == nil { + existingLast = v.String() + } + if existingFirst == "" && len(parts) >= 1 && parts[0] != "" { + existing.FirstName.Set(*vo.NewOptionalString127Must(truncate(parts[0], 127))) + } + if existingLast == "" && len(parts) == 2 && parts[1] != "" { + existing.LastName.Set(*vo.NewOptionalString127Must(truncate(parts[1], 127))) + } + // home/other typed emails and phones are not stored — silently ignore + case "phonenumbers[type eq \"work\"].value", "phonenumbers": + existing.Phone.Set(*vo.NewOptionalString127Must(truncate(strVal, 127))) + case "urn:ietf:params:scim:schemas:extension:enterprise:2.0:user:department": + existing.Department.Set(*vo.NewOptionalString127Must(truncate(strVal, 127))) + case "urn:ietf:params:scim:schemas:extension:enterprise:2.0:user:title": + existing.Position.Set(*vo.NewOptionalString127Must(truncate(strVal, 127))) + case "addresses[type eq \"work\"].locality", "addresses.locality": + existing.City.Set(*vo.NewOptionalString127Must(truncate(strVal, 127))) + case "addresses[type eq \"work\"].country", "addresses.country": + existing.Country.Set(*vo.NewOptionalString127Must(truncate(strVal, 127))) + // home/other typed addresses are not stored — silently ignore + case "externalid": + existing.ExtraIdentifier.Set(*vo.NewOptionalString127Must(truncate(strVal, 127))) + case "urn:ietf:params:scim:schemas:extension:phishingclub:2.0:user:misc": + existing.Misc.Set(*vo.NewOptionalString127Must(truncate(strVal, 127))) + } + + id := existing.ID.MustGet() + if err := s.RecipientRepository.UpdateByID(ctx, &id, existing); err != nil { + return false, errs.Wrap(err) + } + return false, nil +} + +// applyAttributeMap applies a flat attribute map from a no-path PatchOp. +// only attributes that map to our data model are persisted; others are silently ignored. +func (s *Scim) applyAttributeMap( + _ context.Context, + existing *model.Recipient, + config *model.CompanyScimConfig, + recipientID *uuid.UUID, + m map[string]any, +) error { + // collect name sub-attributes first so we can merge them correctly + givenName := "" + familyName := "" + formattedName := "" + + for k, v := range m { + strVal := fmt.Sprintf("%v", v) + switch strings.ToLower(k) { + case "username": + // update the stored scim userName so it round-trips exactly + existing.ScimUserName.Set(*vo.NewOptionalString127Must(truncate(strVal, 127))) + case "active": + // active flag handled at the PatchUser call site after this map is applied + case "externalid": + existing.ExtraIdentifier.Set(*vo.NewOptionalString127Must(truncate(strVal, 127))) + case "displayname": + // displayname is not a stored field; use it as a fallback for name only + // if the IdP also sends name.givenName / name.familyName those take priority + _ = strVal + case "name.givenname": + givenName = strVal + case "name.familyname": + familyName = strVal + case "name.formatted": + formattedName = strVal + case "urn:ietf:params:scim:schemas:extension:enterprise:2.0:user:department": + existing.Department.Set(*vo.NewOptionalString127Must(truncate(strVal, 127))) + case "urn:ietf:params:scim:schemas:extension:enterprise:2.0:user:title": + existing.Position.Set(*vo.NewOptionalString127Must(truncate(strVal, 127))) + case "urn:ietf:params:scim:schemas:extension:phishingclub:2.0:user:misc": + existing.Misc.Set(*vo.NewOptionalString127Must(truncate(strVal, 127))) + } + } + + // apply name fields — explicit sub-attributes take priority over formatted + if givenName != "" { + existing.FirstName.Set(*vo.NewOptionalString127Must(truncate(givenName, 127))) + } + if familyName != "" { + existing.LastName.Set(*vo.NewOptionalString127Must(truncate(familyName, 127))) + } + // use formatted only as a fallback when explicit names were not provided + if givenName == "" && familyName == "" && formattedName != "" { + parts := strings.SplitN(formattedName, " ", 2) + if len(parts) >= 1 && parts[0] != "" { + existing.FirstName.Set(*vo.NewOptionalString127Must(truncate(parts[0], 127))) + } + if len(parts) == 2 && parts[1] != "" { + existing.LastName.Set(*vo.NewOptionalString127Must(truncate(parts[1], 127))) + } + } + + return nil +} + +// ── SCIM <-> model conversion helpers ───────────────────────────────────────── + +// recipientToScimUser maps a model.Recipient to a ScimUser +func recipientToScimUser(r *model.Recipient, baseURL string) ScimUser { + id := "" + if rid, err := r.ID.Get(); err == nil { + id = rid.String() + } + + emailStr := "" + if e, err := r.Email.Get(); err == nil { + emailStr = e.String() + } + + // prefer the stored scim userName; fall back to the email address + userNameStr := emailStr + if v, err := r.ScimUserName.Get(); err == nil && v.String() != "" { + userNameStr = v.String() + } + + firstName := "" + if v, err := r.FirstName.Get(); err == nil { + firstName = v.String() + } + lastName := "" + if v, err := r.LastName.Get(); err == nil { + lastName = v.String() + } + + var name *ScimName + if firstName != "" || lastName != "" { + name = &ScimName{ + GivenName: firstName, + FamilyName: lastName, + Formatted: strings.TrimSpace(firstName + " " + lastName), + } + } + + var emails []ScimEmail + if emailStr != "" { + emails = []ScimEmail{{Value: emailStr, Type: "work", Primary: true}} + } + + var phones []ScimPhoneNumber + if v, err := r.Phone.Get(); err == nil && v.String() != "" { + phones = []ScimPhoneNumber{{Value: v.String(), Type: "work", Primary: true}} + } + + var enterprise *ScimEnterpriseUser + dept := "" + if v, err := r.Department.Get(); err == nil { + dept = v.String() + } + pos := "" + if v, err := r.Position.Get(); err == nil { + pos = v.String() + } + if dept != "" || pos != "" { + enterprise = &ScimEnterpriseUser{ + Department: dept, + Title: pos, + } + } + + // addresses — map city + country to a single work address entry + var addresses []ScimAddress + city := "" + if v, err := r.City.Get(); err == nil { + city = v.String() + } + country := "" + if v, err := r.Country.Get(); err == nil { + country = v.String() + } + if city != "" || country != "" { + addresses = []ScimAddress{{ + Type: "work", + Locality: city, + Country: country, + Primary: true, + }} + } + + externalID := "" + if v, err := r.ExtraIdentifier.Get(); err == nil { + externalID = v.String() + } + + // custom extension — misc + var custom *ScimCustomExtension + if v, err := r.Misc.Get(); err == nil && v.String() != "" { + custom = &ScimCustomExtension{Misc: v.String()} + } + + schemas := []string{scimSchemaUser} + if enterprise != nil { + schemas = append(schemas, scimSchemaEnterpriseUser) + } + if custom != nil { + schemas = append(schemas, scimSchemaCustomExtension) + } + + u := ScimUser{ + Schemas: schemas, + ID: id, + UserName: userNameStr, + Name: name, + Emails: emails, + PhoneNumbers: phones, + EnterpriseUser: enterprise, + Addresses: addresses, + Active: true, + ExternalID: externalID, + CustomExtension: custom, + } + if baseURL != "" && id != "" { + u.Meta = &ScimMeta{ + ResourceType: scimResourceTypeUser, + Location: baseURL + "/Users/" + id, + } + } + return u +} + +// scimUserToRecipient creates a new model.Recipient from a ScimUser +func scimUserToRecipient(scimUser *ScimUser, companyID *uuid.UUID) *model.Recipient { + r := &model.Recipient{} + + // store the original userName so it round-trips exactly + r.ScimUserName = nullable.NewNullableWithValue(*vo.NewOptionalString127Must(truncate(scimUserNameFrom(scimUser), 127))) + + emailStr, _ := canonicalEmail(scimUser) + if emailStr != "" { + if ev, err := vo.NewEmail(emailStr); err == nil { + r.Email = nullable.NewNullableWithValue(*ev) + } + } + r.FirstName = nullable.NewNullableWithValue(*vo.NewOptionalString127Must(truncate(firstNameFrom(scimUser), 127))) + r.LastName = nullable.NewNullableWithValue(*vo.NewOptionalString127Must(truncate(lastNameFrom(scimUser), 127))) + + if phone := primaryPhoneFrom(scimUser); phone != "" { + r.Phone = nullable.NewNullableWithValue(*vo.NewOptionalString127Must(truncate(phone, 127))) + } else { + r.Phone = nullable.NewNullableWithValue(*vo.NewOptionalString127Must("")) + } + + if scimUser.EnterpriseUser != nil { + r.Department = nullable.NewNullableWithValue(*vo.NewOptionalString127Must(truncate(scimUser.EnterpriseUser.Department, 127))) + r.Position = nullable.NewNullableWithValue(*vo.NewOptionalString127Must(truncate(scimUser.EnterpriseUser.Title, 127))) + } else { + r.Department = nullable.NewNullableWithValue(*vo.NewOptionalString127Must("")) + r.Position = nullable.NewNullableWithValue(*vo.NewOptionalString127Must("")) + } + + // addresses — prefer work, fall back to first entry + city, country := primaryAddressFrom(scimUser) + r.City = nullable.NewNullableWithValue(*vo.NewOptionalString127Must(truncate(city, 127))) + r.Country = nullable.NewNullableWithValue(*vo.NewOptionalString127Must(truncate(country, 127))) + + if scimUser.ExternalID != "" { + r.ExtraIdentifier = nullable.NewNullableWithValue(*vo.NewOptionalString127Must(truncate(scimUser.ExternalID, 127))) + } else { + r.ExtraIdentifier = nullable.NewNullableWithValue(*vo.NewOptionalString127Must("")) + } + + // custom extension — misc + if scimUser.CustomExtension != nil && scimUser.CustomExtension.Misc != "" { + r.Misc = nullable.NewNullableWithValue(*vo.NewOptionalString127Must(truncate(scimUser.CustomExtension.Misc, 127))) + } else { + r.Misc = nullable.NewNullableWithValue(*vo.NewOptionalString127Must("")) + } + + if companyID != nil { + r.CompanyID = nullable.NewNullableWithValue(*companyID) + } + return r +} + +// scimUserNameFrom returns the raw userName value to persist as-is. +func scimUserNameFrom(u *ScimUser) string { + return strings.TrimSpace(u.UserName) +} + +// isScimUniqueConflict returns true when the error indicates a unique constraint +// violation, which means a resource with that identifier already exists. +func isScimUniqueConflict(err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "unique") || strings.Contains(msg, "duplicate") +} + +// canonicalEmail extracts the canonical email address from a ScimUser, +// preserving the original case sent by the IdP so userName round-trips exactly. +// preference: first primary email, then first email, then userName. +func canonicalEmail(u *ScimUser) (string, error) { + for _, e := range u.Emails { + if e.Primary && e.Value != "" { + return strings.TrimSpace(e.Value), nil + } + } + for _, e := range u.Emails { + if e.Value != "" { + return strings.TrimSpace(e.Value), nil + } + } + if u.UserName != "" { + return strings.TrimSpace(u.UserName), nil + } + return "", fmt.Errorf("scim user has no email or userName") +} + +// canonicalEmailLower returns the lowercased canonical email, used only for +// case-insensitive dedup lookups against existing recipients. +func canonicalEmailLower(u *ScimUser) (string, error) { + v, err := canonicalEmail(u) + if err != nil { + return "", err + } + return strings.ToLower(v), nil +} + +// firstNameFrom extracts the given name from a ScimUser +func firstNameFrom(u *ScimUser) string { + if u.Name != nil && u.Name.GivenName != "" { + return u.Name.GivenName + } + if u.DisplayName != "" { + parts := strings.SplitN(u.DisplayName, " ", 2) + if len(parts) >= 1 { + return parts[0] + } + } + return "" +} + +// lastNameFrom extracts the family name from a ScimUser +func lastNameFrom(u *ScimUser) string { + if u.Name != nil && u.Name.FamilyName != "" { + return u.Name.FamilyName + } + if u.DisplayName != "" { + parts := strings.SplitN(u.DisplayName, " ", 2) + if len(parts) == 2 { + return parts[1] + } + } + return "" +} + +// primaryPhoneFrom extracts the primary (or first) phone number from a ScimUser +func primaryPhoneFrom(u *ScimUser) string { + for _, p := range u.PhoneNumbers { + if p.Primary && p.Value != "" { + return p.Value + } + } + for _, p := range u.PhoneNumbers { + if p.Value != "" { + return p.Value + } + } + return "" +} + +// truncate truncates a string to max bytes without splitting a UTF-8 rune +func truncate(s string, max int) string { + if len(s) <= max { + return s + } + // walk back to a valid rune boundary + for i := max; i > 0; i-- { + if s[i]&0xC0 != 0x80 { + return s[:i] + } + } + return "" +} + +// scimFilterMatchesUser performs a very simple filter evaluation. +// only "userName eq " and "externalId eq " are handled. +// unrecognised filters always pass (return true) to be permissive. +func scimFilterMatchesUser(filter string, u ScimUser) bool { + lower := strings.ToLower(strings.TrimSpace(filter)) + // e.g. userName eq "user@example.com" + for _, attr := range []string{"username", "externalid"} { + prefix := attr + " eq " + if strings.HasPrefix(lower, prefix) { + want := strings.Trim(lower[len(prefix):], `"' `) + switch attr { + case "username": + return strings.EqualFold(u.UserName, want) + case "externalid": + return strings.EqualFold(u.ExternalID, want) + } + } + } + return true +} + +// scimGroupFilterMatches evaluates a SCIM filter expression against a group. +// only "displayName eq " is supported; unrecognised filters pass. +func scimGroupFilterMatches(filter string, g *model.RecipientGroup) bool { + lower := strings.ToLower(strings.TrimSpace(filter)) + const prefix = "displayname eq " + if strings.HasPrefix(lower, prefix) { + want := strings.Trim(lower[len(prefix):], `"' `) + name := "" + if n, err := g.Name.Get(); err == nil { + name = strings.ToLower(n.String()) + } + return name == want + } + return true +} + +// scimExcludesAttribute returns true when the excludedAttributes query param +// contains the named attribute (case-insensitive, comma-separated list). +func scimExcludesAttribute(excludedAttributes, attr string) bool { + if excludedAttributes == "" { + return false + } + attrLower := strings.ToLower(attr) + for _, part := range strings.Split(excludedAttributes, ",") { + if strings.ToLower(strings.TrimSpace(part)) == attrLower { + return true + } + } + return false +} + +// boolFromPatchValue coerces a PatchOp value to bool +func boolFromPatchValue(v any) bool { + switch val := v.(type) { + case bool: + return val + case string: + return strings.EqualFold(val, "true") + case float64: + return val != 0 + } + return false +} + +// stringFromPatchValue coerces a PatchOp value to string +func stringFromPatchValue(v any) string { + if v == nil { + return "" + } + return fmt.Sprintf("%v", v) +} + +// scimSortUsers sorts a slice of ScimUser in-place by the given attribute. +// only "username", "id", "name.familyname", and "name.givenname" are handled; +// unrecognised attributes are ignored. sortOrder defaults to ascending. +func scimSortUsers(users []ScimUser, sortBy string, sortOrder string) { + descending := strings.EqualFold(sortOrder, "descending") + key := strings.ToLower(sortBy) + + // insertion sort — swap when the left element is out of order relative to right + for i := 1; i < len(users); i++ { + for j := i; j > 0; j-- { + a := strings.ToLower(scimUserSortKey(users[j-1], key)) + b := strings.ToLower(scimUserSortKey(users[j], key)) + // for ascending: swap when a > b (left is larger than right) + // for descending: swap when a < b (left is smaller than right) + outOfOrder := a > b + if descending { + outOfOrder = a < b + } + if outOfOrder { + users[j-1], users[j] = users[j], users[j-1] + } else { + break + } + } + } +} + +// scimUserSortKey returns the string value of the requested sort attribute. +func scimUserSortKey(u ScimUser, key string) string { + switch key { + case "username": + return u.UserName + case "id": + return u.ID + case "name.familyname": + if u.Name != nil { + return u.Name.FamilyName + } + case "name.givenname": + if u.Name != nil { + return u.Name.GivenName + } + case "displayname": + return u.DisplayName + } + return "" +} diff --git a/frontend/src/lib/api/api.js b/frontend/src/lib/api/api.js index 32ec67a..2731535 100644 --- a/frontend/src/lib/api/api.js +++ b/frontend/src/lib/api/api.js @@ -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}`)); + } } }; diff --git a/frontend/src/lib/api/apiProxy.js b/frontend/src/lib/api/apiProxy.js index 6c0b2d9..4b49e01 100644 --- a/frontend/src/lib/api/apiProxy.js +++ b/frontend/src/lib/api/apiProxy.js @@ -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); + } +}); diff --git a/frontend/src/lib/components/modal/ScimModal.svelte b/frontend/src/lib/components/modal/ScimModal.svelte new file mode 100644 index 0000000..0f3580d --- /dev/null +++ b/frontend/src/lib/components/modal/ScimModal.svelte @@ -0,0 +1,365 @@ + + + +
+ {#if isLoading} +
+

Loading...

+
+ {:else if showTokenReveal && revealedToken} + +
+

+ ⚠ Copy this token now — it will not be shown again. +

+
+ + +
+ +
+ {:else if !scimConfig} + +

+ Set up SCIM provisioning for {company?.name}.
A bearer token will be generated once and must be copied into your identity provider. +

+
+ +
+ {:else} + + + +
+
+ Token prefix + + {scimConfig.tokenPrefix ? scimConfig.tokenPrefix + '...' : '—'} + + Last sync + {formatDate(scimConfig.lastSyncAt)} +
+ + +
+

+ SCIM Base URL - provide this to your identity provider +

+
+ + +
+
+
+ + +
+
+

Provisioning

+

+ {scimConfig.enabled + ? 'Active - IdP can push changes.' + : 'Paused - incoming SCIM requests will be rejected.'} +

+
+ +
+ + +
+ + +
+ {/if} +
+
+ + + +

+ Are you sure you want to rotate the SCIM bearer token for + {company?.name}? +

+

+ the existing token will be immediately invalidated. your identity provider must be updated with + the new token before provisioning can resume. +

+
+ + + +

+ Are you sure you want to delete the SCIM configuration for + {company?.name}? +

+

+ the bearer token will be invalidated and future syncs will stop. previously provisioned + recipients will not be removed. +

+
diff --git a/frontend/src/routes/company/+page.svelte b/frontend/src/routes/company/+page.svelte index 3510944..8ee6278 100644 --- a/frontend/src/routes/company/+page.svelte +++ b/frontend/src/routes/company/+page.svelte @@ -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)} /> openExportCompanyModal(company)} /> + openScimModal(company)} /> goto(`/company/${company.id}/stats`)} @@ -457,6 +472,8 @@ + + {group.name} + {#if group.scimEnabled} + + SCIM + + {/if}