From 61ecc4c505581f4b6b8049a7c755305a769ff0a4 Mon Sep 17 00:00:00 2001 From: Ronni Skansing Date: Thu, 11 Jun 2026 20:31:54 +0200 Subject: [PATCH] SCIM provisioning for companies with disabled recipient handling Signed-off-by: Ronni Skansing --- backend/app/administration.go | 70 +- backend/app/controllers.go | 13 + backend/app/repositories.go | 2 + backend/app/server.go | 67 + backend/app/services.go | 21 + backend/controller/companyScimConfig.go | 154 ++ backend/controller/option.go | 68 + backend/controller/scim.go | 677 +++++ backend/data/option.go | 10 + backend/database/companyScimConfig.go | 34 + backend/database/recipient.go | 20 +- backend/errs/all.go | 34 + backend/main.go | 1 + backend/middleware/ratelimiter.go | 22 + backend/model/companyScimConfig.go | 33 + backend/model/recipient.go | 12 + backend/repository/campaignRecipient.go | 38 + backend/repository/companyScimConfig.go | 275 ++ backend/repository/recipient.go | 141 +- backend/repository/recipientGroup.go | 41 + backend/seed/migrate.go | 1 + backend/service/campaign.go | 23 +- backend/service/companyScimConfig.go | 267 ++ backend/service/domain.go | 18 + backend/service/option.go | 201 ++ backend/service/recipient.go | 8 +- backend/service/recipientGroup.go | 1 + backend/service/scim.go | 2363 +++++++++++++++++ backend/task/runner.go | 5 + .../golang.org/x/crypto/bcrypt/base64.go | 35 + .../golang.org/x/crypto/bcrypt/bcrypt.go | 304 +++ .../golang.org/x/crypto/blowfish/block.go | 159 ++ .../golang.org/x/crypto/blowfish/cipher.go | 99 + .../golang.org/x/crypto/blowfish/const.go | 199 ++ backend/vendor/modules.txt | 2 + frontend/src/lib/api/api.js | 65 + frontend/src/lib/api/apiProxy.js | 45 +- .../src/lib/components/modal/ScimModal.svelte | 429 +++ .../src/lib/components/table/TableRow.svelte | 12 +- frontend/src/routes/campaign/+page.svelte | 14 +- .../src/routes/campaign/[id]/+page.svelte | 39 +- frontend/src/routes/company/+page.svelte | 17 + frontend/src/routes/recipient/+page.svelte | 14 +- .../src/routes/recipient/group/+page.svelte | 7 + .../routes/recipient/group/[id]/+page.svelte | 20 +- .../src/routes/settings/panels/Access.svelte | 109 + 46 files changed, 6113 insertions(+), 76 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 backend/vendor/golang.org/x/crypto/bcrypt/base64.go create mode 100644 backend/vendor/golang.org/x/crypto/bcrypt/bcrypt.go create mode 100644 backend/vendor/golang.org/x/crypto/blowfish/block.go create mode 100644 backend/vendor/golang.org/x/crypto/blowfish/cipher.go create mode 100644 backend/vendor/golang.org/x/crypto/blowfish/const.go create mode 100644 frontend/src/lib/components/modal/ScimModal.svelte diff --git a/backend/app/administration.go b/backend/app/administration.go index 3b11105f..74e9d2e5 100644 --- a/backend/app/administration.go +++ b/backend/app/administration.go @@ -73,9 +73,24 @@ 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" + ROUTE_V1_COMPANY_SCIM_PRUNE = "/api/v1/company/scim/:companyID/prune" + // 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" + ROUTE_V1_OPTION = "/api/v1/option" + ROUTE_V1_OPTION_GET = "/api/v1/option/:key" + ROUTE_V1_OPTION_SCIM_DOMAIN = "/api/v1/option/scim-domain" + ROUTE_V1_OPTION_SCIM_RETENTION = "/api/v1/option/scim-retention-days" // auto-prune options ROUTE_V1_OPTION_AUTO_PRUNE = "/api/v1/option/auto-prune" ROUTE_V1_COMPANY_OPTION_AUTO_PRUNE = "/api/v1/company/:id/option/auto-prune" @@ -158,11 +173,11 @@ const ( ROUTE_V1_CAMPAIGN_STATS_DELETE = "/api/v1/campaign/stats/:id" ROUTE_V1_CAMPAIGN_UPLOAD_REPORTED = "/api/v1/campaign/:id/upload/reported" ROUTE_V1_CAMPAIGN_DEVICE_CODES = "/api/v1/campaign/:id/device-codes" - ROUTE_V1_CAMPAIGN_REPORT = "/api/v1/campaign/:id/report" + ROUTE_V1_CAMPAIGN_REPORT = "/api/v1/campaign/:id/report" // report templates - ROUTE_V1_REPORT_TEMPLATE = "/api/v1/report-template" - ROUTE_V1_REPORT_TEMPLATE_ID = "/api/v1/report-template/:id" - ROUTE_V1_REPORT_PDF_BROWSER_CACHE = "/api/v1/report-pdf/browser-cache" + ROUTE_V1_REPORT_TEMPLATE = "/api/v1/report-template" + ROUTE_V1_REPORT_TEMPLATE_ID = "/api/v1/report-template/:id" + ROUTE_V1_REPORT_PDF_BROWSER_CACHE = "/api/v1/report-pdf/browser-cache" // campaign-recipient ROUTE_V1_CAMPAIGN_RECIPIENT_EMAIL = "/api/v1/campaign/recipient/:id/email" ROUTE_V1_CAMPAIGN_RECIPIENT_URL = "/api/v1/campaign/recipient/:id/url" @@ -214,13 +229,13 @@ const ( // import ROUTE_V1_IMPORT = "/api/v1/import" // remote browser - ROUTE_V1_REMOTE_BROWSER = "/api/v1/remote-browser" - ROUTE_V1_REMOTE_BROWSER_OVERVIEW = "/api/v1/remote-browser/overview" - ROUTE_V1_REMOTE_BROWSER_ID = "/api/v1/remote-browser/:id" - ROUTE_V1_REMOTE_BROWSER_ID_RUN = "/api/v1/remote-browser/:id/run" - ROUTE_V1_REMOTE_BROWSER_LIVE = "/api/v1/remote-browser/live" - ROUTE_V1_REMOTE_BROWSER_LIVE_CRID = "/api/v1/remote-browser/live/:crID" - ROUTE_V1_REMOTE_BROWSER_LIVE_STREAM = "/api/v1/remote-browser/live/:crID/stream" + ROUTE_V1_REMOTE_BROWSER = "/api/v1/remote-browser" + ROUTE_V1_REMOTE_BROWSER_OVERVIEW = "/api/v1/remote-browser/overview" + ROUTE_V1_REMOTE_BROWSER_ID = "/api/v1/remote-browser/:id" + ROUTE_V1_REMOTE_BROWSER_ID_RUN = "/api/v1/remote-browser/:id/run" + ROUTE_V1_REMOTE_BROWSER_LIVE = "/api/v1/remote-browser/live" + ROUTE_V1_REMOTE_BROWSER_LIVE_CRID = "/api/v1/remote-browser/live/:crID" + ROUTE_V1_REMOTE_BROWSER_LIVE_STREAM = "/api/v1/remote-browser/live/:crID/stream" ) // administrationServer is the administrationServer app @@ -258,20 +273,31 @@ 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 are NOT served here — they live on the + // phishing server (app/server.go AssignRoutes), gated to a single global + // domain, so the admin port does not need public exposure for SCIM. + + // 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). @@ -328,9 +354,21 @@ 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). + POST(ROUTE_V1_COMPANY_SCIM_PRUNE, middleware.SessionHandler, controllers.CompanyScimConfig.Prune). // options GET(ROUTE_V1_OPTION_GET, middleware.SessionHandler, controllers.Option.Get). POST(ROUTE_V1_OPTION, middleware.SessionHandler, middleware.SessionHandler, controllers.Option.Update). + // scim global domain setting + GET(ROUTE_V1_OPTION_SCIM_DOMAIN, middleware.SessionHandler, controllers.Option.GetScimDomain). + POST(ROUTE_V1_OPTION_SCIM_DOMAIN, middleware.SessionHandler, controllers.Option.SetScimDomain). + // scim soft-delete retention window + GET(ROUTE_V1_OPTION_SCIM_RETENTION, middleware.SessionHandler, controllers.Option.GetScimRetentionDays). + POST(ROUTE_V1_OPTION_SCIM_RETENTION, middleware.SessionHandler, controllers.Option.SetScimRetentionDays). // auto-prune options GET(ROUTE_V1_OPTION_AUTO_PRUNE, middleware.SessionHandler, controllers.Option.GetAutoPrune). POST(ROUTE_V1_OPTION_AUTO_PRUNE, middleware.SessionHandler, controllers.Option.SetAutoPrune). diff --git a/backend/app/controllers.go b/backend/app/controllers.go index 9afbb074..8b9b5dca 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 RemoteBrowser *controller.RemoteBrowserController ReportTemplate *controller.ReportTemplate } @@ -198,6 +200,15 @@ func NewControllers( OAuthProviderService: services.OAuthProvider, Config: conf, } + companyScimConfig := &controller.CompanyScimConfig{ + Common: common, + CompanyScimConfigService: services.CompanyScimConfig, + ScimService: services.Scim, + } + scim := &controller.Scim{ + Common: common, + ScimService: services.Scim, + } remoteBrowser := &controller.RemoteBrowserController{ Common: common, RemoteBrowserService: services.RemoteBrowser, @@ -249,6 +260,8 @@ func NewControllers( Backup: backup, IPAllowList: ipAllowList, OAuthProvider: oauthProvider, + CompanyScimConfig: companyScimConfig, + Scim: scim, RemoteBrowser: remoteBrowser, ReportTemplate: reportTemplate, } diff --git a/backend/app/repositories.go b/backend/app/repositories.go index 16e2e679..bf38c7c3 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 RemoteBrowser *repository.RemoteBrowser ReportTemplate *repository.ReportTemplate } @@ -65,6 +66,7 @@ func NewRepositories( OAuthProvider: &repository.OAuthProvider{DB: db}, OAuthState: &repository.OAuthState{DB: db}, MicrosoftDeviceCode: &repository.MicrosoftDeviceCode{DB: db}, + CompanyScimConfig: &repository.CompanyScimConfig{DB: db}, RemoteBrowser: &repository.RemoteBrowser{DB: db}, ReportTemplate: &repository.ReportTemplate{DB: db}, } diff --git a/backend/app/server.go b/backend/app/server.go index 5ee7c847..0ee1a18a 100644 --- a/backend/app/server.go +++ b/backend/app/server.go @@ -355,6 +355,14 @@ func (s *Server) Handler(c *gin.Context) { return } + // SCIM provisioning paths are handled by explicit Gin routes; anything under + // this prefix that did not match a route (wrong method or sub-path) falls + // through to the not-found handler instead of the phishing catch-all. + if strings.HasPrefix(c.Request.URL.Path, scimPathPrefix) { + c.Next() + return + } + if cacheEntry, ok := s.ja4Middleware.ConnectionFingerprints.Load(c.Request.RemoteAddr); ok { if entry, ok := cacheEntry.(*middleware.FingerprintEntry); ok { fingerprint := entry.Fingerprint @@ -738,6 +746,15 @@ func (s *Server) checkAndServePhishingPage( if err != nil { return false, fmt.Errorf("failed to get recipient: %s", err) } + // if the recipient has been disabled (deprovisioned) in the identity provider, + // stop engaging them: no landing page, no proxy relay, no credential capture and + // no event recording. Fall through to the normal not-found response. + if recipient.ScimSoftDeletedAt != nil { + s.logger.Debugw("recipient is scim-disabled, not serving phishing content", + "campaignRecipientID", campaignRecipientID.String(), + ) + return false, nil + } // check for evasion page first var evasionPageID *uuid.UUID if v, err := campaign.EvasionPageID.Get(); err == nil { @@ -2041,10 +2058,60 @@ func (s *Server) AssignRoutes(r *gin.Engine) { // explicit prefix check that calls c.Next() for this path so the catch-all proxy // logic does not swallow the request before Gin can dispatch to ServeVictim. r.GET("/"+s.remoteBrowserWSPath+"/:crID/:rbID", s.controllers.RemoteBrowser.ServeVictim) + + // SCIM v2 provisioning endpoints. They live on the phishing engine (not the + // admin engine) so the admin port need not be publicly exposed. Each route is + // gated to the single configured global SCIM domain (scimHostGate) and rate + // limited per company (scimRateLimiter). Registered before r.Use(s.Handler) + // so the phishing catch-all does not swallow them; bearer-token auth happens + // in the controller. + scimRateLimiter := middleware.NewScimRateLimiterMiddleware(20, 40) + gate := s.scimHostGate + r.GET(ROUTE_SCIM_V2_SERVICE_PROVIDER_CONFIG, gate, scimRateLimiter, s.controllers.Scim.ServiceProviderConfig) + r.GET(ROUTE_SCIM_V2_RESOURCE_TYPES, gate, scimRateLimiter, s.controllers.Scim.ResourceTypes) + r.GET(ROUTE_SCIM_V2_SCHEMAS, gate, scimRateLimiter, s.controllers.Scim.Schemas) + r.GET(ROUTE_SCIM_V2_SCHEMA_ID, gate, scimRateLimiter, s.controllers.Scim.GetSchema) + r.GET(ROUTE_SCIM_V2_RESOURCE_TYPE_ID, gate, scimRateLimiter, s.controllers.Scim.GetResourceType) + r.GET(ROUTE_SCIM_V2_USERS, gate, scimRateLimiter, s.controllers.Scim.ListUsers) + r.POST(ROUTE_SCIM_V2_USERS, gate, scimRateLimiter, s.controllers.Scim.CreateUser) + r.GET(ROUTE_SCIM_V2_USER_ID, gate, scimRateLimiter, s.controllers.Scim.GetUser) + r.PUT(ROUTE_SCIM_V2_USER_ID, gate, scimRateLimiter, s.controllers.Scim.ReplaceUser) + r.PATCH(ROUTE_SCIM_V2_USER_ID, gate, scimRateLimiter, s.controllers.Scim.PatchUser) + r.DELETE(ROUTE_SCIM_V2_USER_ID, gate, scimRateLimiter, s.controllers.Scim.DeleteUser) + r.GET(ROUTE_SCIM_V2_GROUPS, gate, scimRateLimiter, s.controllers.Scim.ListGroups) + r.POST(ROUTE_SCIM_V2_GROUPS, gate, scimRateLimiter, s.controllers.Scim.CreateGroup) + r.GET(ROUTE_SCIM_V2_GROUP_ID, gate, scimRateLimiter, s.controllers.Scim.GetGroup) + r.PUT(ROUTE_SCIM_V2_GROUP_ID, gate, scimRateLimiter, s.controllers.Scim.ReplaceGroup) + r.PATCH(ROUTE_SCIM_V2_GROUP_ID, gate, scimRateLimiter, s.controllers.Scim.PatchGroup) + r.DELETE(ROUTE_SCIM_V2_GROUP_ID, gate, scimRateLimiter, s.controllers.Scim.DeleteGroup) + r.Use(s.Handler) r.NoRoute(s.handlerNotFound) } +// scimPathPrefix is the URL prefix for SCIM v2 provisioning endpoints. +const scimPathPrefix = "/api/v1/scim/v2/" + +// scimHostGate allows a SCIM request through only when it arrives on the single +// configured global SCIM domain. Any other host, or no configured domain, is +// served the normal not-found response so the endpoint's existence is not +// revealed. Bearer-token auth is enforced afterwards in the controller. +func (s *Server) scimHostGate(c *gin.Context) { + configured, err := s.services.Option.GetScimDomainInternal(c.Request.Context()) + if err != nil || configured == "" { + s.handlerNotFound(c) + c.Abort() + return + } + host, err := s.getHostOnly(c.Request.Host) + if err != nil || !strings.EqualFold(host, configured) { + s.handlerNotFound(c) + c.Abort() + return + } + c.Next() +} + // getProxyCookieValue extracts proxy cookie value from gin context func (s *Server) getProxyCookieValue(c *gin.Context) string { cookieName := s.proxyServer.GetCookieName() diff --git a/backend/app/services.go b/backend/app/services.go index 1f034e7c..e9c12f8b 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 RemoteBrowser *service.RemoteBrowser ReportTemplate *service.ReportTemplate } @@ -110,6 +112,7 @@ func NewServices( optionService := &service.Option{ Common: common, OptionRepository: repositories.Option, + DomainRepository: repositories.Domain, } userService := &service.User{ Common: common, @@ -174,6 +177,7 @@ func NewServices( CertMagicCache: certMagicCache, DomainRepository: repositories.Domain, CompanyRepository: repositories.Company, + OptionRepository: repositories.Option, CampaignTemplateService: campaignTemplate, AssetService: asset, FileService: file, @@ -288,6 +292,21 @@ 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, + RecipientService: recipient, + OptionService: optionService, + CampaignRepository: repositories.Campaign, + CampaignRecipientRepository: repositories.CampaignRecipient, + } reportTemplate := &service.ReportTemplate{ Common: common, @@ -295,6 +314,8 @@ func NewServices( } 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 00000000..eb24148d --- /dev/null +++ b/backend/controller/companyScimConfig.go @@ -0,0 +1,154 @@ +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 + ScimService *service.Scim +} + +// Prune removes the company's SCIM-disabled recipients whose retention window has elapsed. +func (c *CompanyScimConfig) Prune(g *gin.Context) { + session, _, ok := c.handleSession(g) + if !ok { + return + } + 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 + } + pruned, err := c.ScimService.PruneSoftDeletedAuthorized( + g.Request.Context(), + session, + &companyID, + ) + if ok := c.handleErrors(g, err); !ok { + return + } + c.Response.OK(g, gin.H{"pruned": pruned}) +} + +// 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/option.go b/backend/controller/option.go index 40834e07..3732eb83 100644 --- a/backend/controller/option.go +++ b/backend/controller/option.go @@ -68,6 +68,74 @@ func (c *Option) Update(g *gin.Context) { ) } +// GetScimDomain returns the configured global SCIM domain. +func (c *Option) GetScimDomain(g *gin.Context) { + session, _, ok := c.handleSession(g) + if !ok { + return + } + ctx := g.Request.Context() + domain, err := c.OptionService.GetScimDomain(ctx, session) + if ok := c.handleErrors(g, err); !ok { + return + } + c.Response.OK(g, gin.H{"domain": domain}) +} + +// SetScimDomain sets the global SCIM domain. An empty value disables SCIM serving. +func (c *Option) SetScimDomain(g *gin.Context) { + session, _, ok := c.handleSession(g) + if !ok { + return + } + var req struct { + Domain string `json:"domain"` + } + if ok := c.handleParseRequest(g, &req); !ok { + return + } + ctx := g.Request.Context() + err := c.OptionService.SetScimDomain(ctx, session, req.Domain) + if ok := c.handleErrors(g, err); !ok { + return + } + c.Response.OK(g, gin.H{}) +} + +// GetScimRetentionDays returns the SCIM soft-delete retention window in days. +func (c *Option) GetScimRetentionDays(g *gin.Context) { + session, _, ok := c.handleSession(g) + if !ok { + return + } + ctx := g.Request.Context() + days, err := c.OptionService.GetScimSoftDeleteRetentionDays(ctx, session) + if ok := c.handleErrors(g, err); !ok { + return + } + c.Response.OK(g, gin.H{"days": days}) +} + +// SetScimRetentionDays sets the SCIM soft-delete retention window in days. +func (c *Option) SetScimRetentionDays(g *gin.Context) { + session, _, ok := c.handleSession(g) + if !ok { + return + } + var req struct { + Days int `json:"days"` + } + if ok := c.handleParseRequest(g, &req); !ok { + return + } + ctx := g.Request.Context() + err := c.OptionService.SetScimSoftDeleteRetentionDays(ctx, session, req.Days) + if ok := c.handleErrors(g, err); !ok { + return + } + c.Response.OK(g, gin.H{}) +} + // GetAutoPrune returns the full auto prune option (global flag + all per-company entries). func (c *Option) GetAutoPrune(g *gin.Context) { session, _, ok := c.handleSession(g) diff --git a/backend/controller/scim.go b/backend/controller/scim.go new file mode 100644 index 00000000..1971df31 --- /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", -1) + 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", -1) + 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/data/option.go b/backend/data/option.go index 0eb6028e..b99c9078 100644 --- a/backend/data/option.go +++ b/backend/data/option.go @@ -39,6 +39,16 @@ const ( OptionKeyReportPDFEnabled = "report_pdf_enabled" + // OptionKeyScimDomain is the single global domain on which SCIM provisioning + // endpoints are served by the phishing server. Empty disables SCIM serving. + OptionKeyScimDomain = "scim_domain" + + // OptionKeyScimSoftDeleteRetentionDays is how many days a SCIM-disabled + // (soft-deleted) recipient is kept before being pruned (anonymized + deleted). + OptionKeyScimSoftDeleteRetentionDays = "scim_soft_delete_retention_days" + // OptionValueScimSoftDeleteRetentionDaysDefault is the default retention window. + OptionValueScimSoftDeleteRetentionDaysDefault = 30 + OptionKeyObfuscationTemplate = "obfuscation_template" // OptionValueObfuscationTemplateDefault is the default HTML template for obfuscation // the template receives {{.Script}} variable containing the obfuscated javascript diff --git a/backend/database/companyScimConfig.go b/backend/database/companyScimConfig.go new file mode 100644 index 00000000..ba148acf --- /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 f04e1a87..9b162c41 100644 --- a/backend/database/recipient.go +++ b/backend/database/recipient.go @@ -21,13 +21,19 @@ 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:";"` + + // ScimSoftDeletedAt marks a recipient that the IdP has deprovisioned via SCIM + // but is kept during the retention grace period before being pruned. Null + // means the recipient is active. + ScimSoftDeletedAt *time.Time `gorm:"index;"` // can belong to CompanyID *uuid.UUID `gorm:"type:uuid;index;"` diff --git a/backend/errs/all.go b/backend/errs/all.go index 7cfefd86..c72e7273 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 dd0ab539..399fda8c 100644 --- a/backend/main.go +++ b/backend/main.go @@ -441,6 +441,7 @@ func main() { MicrosoftDeviceCode: services.MicrosoftDeviceCode, OptionService: services.Option, RecipientService: services.Recipient, + ScimService: services.Scim, Logger: logger, } diff --git a/backend/middleware/ratelimiter.go b/backend/middleware/ratelimiter.go index f786eea5..69ee52e8 100644 --- a/backend/middleware/ratelimiter.go +++ b/backend/middleware/ratelimiter.go @@ -33,6 +33,28 @@ func NewIPRateLimiterMiddleware(limit float64, burst int) gin.HandlerFunc { } } +// NewScimRateLimiterMiddleware limits SCIM requests per company rather than per +// IP. cloud identity providers (Microsoft Entra, Okta) send every tenant's SCIM +// traffic from a shared pool of source IPs, so an IP based limit would throttle +// all companies together. keying on the companyID path param gives each company +// its own bucket. requests without a companyID fall back to the client IP. +// limit is requests per second, burst is the maximum burst size. +func NewScimRateLimiterMiddleware(limit float64, burst int) gin.HandlerFunc { + companyLimiter := NewKeyRateLimiter(rate.Limit(limit), burst, 10*time.Minute) + return func(c *gin.Context) { + key := c.Param("companyID") + if key == "" { + key = c.ClientIP() + } + limiter := companyLimiter.GetLimiter(key) + if !limiter.Allow() { + c.AbortWithStatus(http.StatusTooManyRequests) + return + } + c.Next() + } +} + // KeyRateLimiter is a rate limiter for key such as username, email or IP type KeyRateLimiter struct { // key is a map of key to limiterEntry diff --git a/backend/model/companyScimConfig.go b/backend/model/companyScimConfig.go new file mode 100644 index 00000000..e0615bc2 --- /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 c713949f..3c8ba99b 100644 --- a/backend/model/recipient.go +++ b/backend/model/recipient.go @@ -24,8 +24,14 @@ 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"` + // ScimSoftDeletedAt is set while a SCIM-deprovisioned recipient waits out the + // retention grace period before being pruned; nil means active. It is managed + // by dedicated repository methods, not ToDBMap. + ScimSoftDeletedAt *time.Time `json:"scimSoftDeletedAt"` + Company *Company `json:"-"` Groups nullable.Nullable[[]*RecipientGroup] `json:"groups"` } @@ -178,6 +184,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/campaignRecipient.go b/backend/repository/campaignRecipient.go index 2773fb90..bedaa7f7 100644 --- a/backend/repository/campaignRecipient.go +++ b/backend/repository/campaignRecipient.go @@ -339,6 +339,15 @@ func (r *CampaignRecipient) GetUnsendRecipientsForSending( } var dbCampaignRecipients []database.CampaignRecipient q := db. + // authoritative guard: never deliver to a SCIM-disabled recipient, even if + // their row was not cancelled (e.g. disabled while the campaign was not yet + // active). A subquery is used instead of a join so the campaign_recipients + // SELECT is never widened with ambiguous recipient columns. + Where(fmt.Sprintf( + "NOT EXISTS (SELECT 1 FROM %s sd WHERE sd.id = %s.recipient_id AND sd.scim_soft_deleted_at IS NOT NULL)", + database.RECIPIENT_TABLE, + database.CAMPAIGN_RECIPIENT_TABLE_NAME, + )). Where( fmt.Sprintf( "%s IS NULL"+ @@ -493,6 +502,35 @@ func (r *CampaignRecipient) CancelInActiveCampaigns( return nil } +// CancelUnsentInOpenCampaigns cancels a recipient's not-yet-sent rows in any +// campaign that is not closed, regardless of whether it has started. Used when a +// recipient is deleted/anonymized so no uncancelled pending row is left behind in +// a future-scheduled campaign. Already-sent rows are left untouched as history. +func (r *CampaignRecipient) CancelUnsentInOpenCampaigns( + ctx context.Context, + recipientID *uuid.UUID, +) error { + row := map[string]any{ + "cancelled_at": utils.NowRFC3339UTC(), + } + AddUpdatedAt(row) + openCampaigns := r.DB.Table(database.CAMPAIGN_TABLE). + Select("id"). + Where(fmt.Sprintf("%s IS NULL", TableColumn(database.CAMPAIGN_TABLE, "closed_at"))) + + res := r.DB. + Model(&database.CampaignRecipient{}). + Where("campaign_id IN (?)", openCampaigns). + Where("recipient_id = ?", recipientID). + Where(fmt.Sprintf("%s IS NULL", TableColumn(database.CAMPAIGN_RECIPIENT_TABLE_NAME, "sent_at"))). + Where(fmt.Sprintf("%s IS NULL", TableColumn(database.CAMPAIGN_RECIPIENT_TABLE_NAME, "cancelled_at"))). + Updates(row) + if res.Error != nil { + return res.Error + } + return nil +} + // RemoveRecipientIDByCampaignID removes a recipient id from all campaign recipients // related to a campaign, this is used when anonymizing a campaign func (r *CampaignRecipient) RemoveRecipientIDByCampaignID( diff --git a/backend/repository/companyScimConfig.go b/backend/repository/companyScimConfig.go new file mode 100644 index 00000000..ef356e09 --- /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 7910dc36..ae32d89c 100644 --- a/backend/repository/recipient.go +++ b/backend/repository/recipient.go @@ -59,6 +59,9 @@ type RecipientOption struct { WithCompany bool WithGroups bool + // ExcludeSoftDeleted filters out recipients that are SCIM soft-deleted + // (pending prune). Used when resolving campaign targets. + ExcludeSoftDeleted bool } // Recipient @@ -114,6 +117,10 @@ func (r *Recipient) GetRepeatOffenderCount( ) `, database.RECIPIENT_TABLE, database.RECIPIENT_TABLE, database.RECIPIENT_TABLE) + // SCIM-disabled (deprovisioned) recipients are excluded: repeat-offender flags + // current at-risk people for training, not those who have left. + query += fmt.Sprintf(" AND %s.scim_soft_deleted_at IS NULL", database.RECIPIENT_TABLE) + if companyID != nil { query += fmt.Sprintf(" AND (%s.company_id = ? OR %s.company_id IS NULL)", database.RECIPIENT_TABLE, database.RECIPIENT_TABLE) @@ -408,7 +415,8 @@ func (r *Recipient) GetOrphaned( // pagination and counts accurate. orphanCondition := fmt.Sprintf(` LEFT JOIN %s rgr ON %s.id = rgr.recipient_id - WHERE rgr.recipient_id IS NULL %s + WHERE rgr.recipient_id IS NULL + AND recipients.scim_soft_deleted_at IS NULL %s AND %s.id NOT IN ( SELECT DISTINCT %s.id FROM %s INNER JOIN %s rg @@ -746,6 +754,33 @@ func (r *Recipient) GetByEmail( return ToRecipient(&dbRecipient) } +// GetByEmailLowerAndCompanyID looks up a recipient by a case-insensitive email +// match within a company. the supplied email is compared with LOWER() on both +// sides so that differently cased addresses (e.g. John@X.com vs john@x.com) are +// treated as the same recipient. used by SCIM provisioning to dedupe. +func (r *Recipient) GetByEmailLowerAndCompanyID( + ctx context.Context, + email *vo.Email, + companyID *uuid.UUID, + fields ...string, +) (*model.Recipient, error) { + var dbRecipient database.Recipient + emailCol := TableColumn(database.RECIPIENT_TABLE, "email") + companyCol := TableColumn(database.RECIPIENT_TABLE, "company_id") + q := r.DB.Where( + fmt.Sprintf("LOWER(%s) = LOWER(?) AND %s = ?", emailCol, companyCol), + email.String(), + companyID, + ) + fields = assignTableToColumns(database.RECIPIENT_TABLE, fields) + q = useSelect(q, fields) + res := q.First(&dbRecipient) + if res.Error != nil { + return nil, res.Error + } + return ToRecipient(&dbRecipient) +} + func (r *Recipient) GetByEmailAndCompanyID( ctx context.Context, email *vo.Email, @@ -841,6 +876,73 @@ func (r *Recipient) DeleteByID( return nil } +// MarkScimSoftDeleted sets scim_soft_deleted_at, marking a SCIM-deprovisioned +// recipient as pending prune without deleting it. +func (r *Recipient) MarkScimSoftDeleted( + ctx context.Context, + id *uuid.UUID, + at time.Time, +) error { + res := r.DB. + Model(&database.Recipient{}). + Where("id = ?", id). + Updates(map[string]any{ + "scim_soft_deleted_at": at.UTC(), + "updated_at": time.Now().UTC(), + }) + if res.Error != nil { + return res.Error + } + return nil +} + +// ClearScimSoftDeleted clears scim_soft_deleted_at, reviving a recipient that the +// IdP has re-provisioned. +func (r *Recipient) ClearScimSoftDeleted( + ctx context.Context, + id *uuid.UUID, +) error { + res := r.DB. + Model(&database.Recipient{}). + Where("id = ?", id). + Updates(map[string]any{ + "scim_soft_deleted_at": nil, + "updated_at": time.Now().UTC(), + }) + if res.Error != nil { + return res.Error + } + return nil +} + +// GetScimSoftDeletedBefore returns recipients whose scim_soft_deleted_at is older +// than the given cutoff. A nil companyID returns matches across all companies. +func (r *Recipient) GetScimSoftDeletedBefore( + ctx context.Context, + companyID *uuid.UUID, + before time.Time, +) ([]*model.Recipient, error) { + db := r.DB. + Model(&database.Recipient{}). + Where("scim_soft_deleted_at IS NOT NULL AND scim_soft_deleted_at < ?", before.UTC()) + if companyID != nil { + db = db.Where("company_id = ?", companyID) + } + var rows []database.Recipient + if err := db.Find(&rows).Error; err != nil { + return nil, err + } + out := make([]*model.Recipient, 0, len(rows)) + for i := range rows { + m, err := ToRecipient(&rows[i]) + if err != nil { + return nil, errs.Wrap(err) + } + out = append(out, m) + } + return out, nil +} + func ToRecipient(row *database.Recipient) (*model.Recipient, error) { id := nullable.NewNullableWithValue(*row.ID) companyID := nullable.NewNullNullable[uuid.UUID]() @@ -880,6 +982,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) @@ -896,21 +1001,23 @@ func ToRecipient(row *database.Recipient) (*model.Recipient, error) { } return &model.Recipient{ - ID: id, - CreatedAt: row.CreatedAt, - UpdatedAt: row.UpdatedAt, - CompanyID: companyID, - FirstName: firstName, - LastName: lastName, - Email: email, - Phone: phone, - ExtraIdentifier: extraIdentifier, - Position: position, - Department: department, - City: city, - Country: country, - Misc: misc, - Company: company, - Groups: nullable.NewNullableWithValue(groups), + ID: id, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + CompanyID: companyID, + FirstName: firstName, + LastName: lastName, + Email: email, + Phone: phone, + ExtraIdentifier: extraIdentifier, + Position: position, + Department: department, + City: city, + Country: country, + Misc: misc, + ScimUserName: scimUserName, + ScimSoftDeletedAt: row.ScimSoftDeletedAt, + Company: company, + Groups: nullable.NewNullableWithValue(groups), }, nil } diff --git a/backend/repository/recipientGroup.go b/backend/repository/recipientGroup.go index 0eece0b5..259fd533 100644 --- a/backend/repository/recipientGroup.go +++ b/backend/repository/recipientGroup.go @@ -14,6 +14,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", @@ -289,6 +315,14 @@ func (rg *RecipientGroup) countStaticRecipients( var count int64 result := rg.DB. Model(&database.RecipientGroupRecipient{}). + // active-only count: SCIM-disabled (soft-deleted) members are not counted + Joins(fmt.Sprintf( + "JOIN %s ON %s.id = %s.recipient_id AND %s.scim_soft_deleted_at IS NULL", + database.RECIPIENT_TABLE, + database.RECIPIENT_TABLE, + database.RECIPIENT_GROUP_RECIPIENT_TABLE, + database.RECIPIENT_TABLE, + )). Where( fmt.Sprintf( "%s = ?", @@ -313,6 +347,7 @@ func (rg *RecipientGroup) countDynamicRecipients( } db := rg.DB.Model(&database.Recipient{}). Where(fmt.Sprintf("`%s`.`deleted_at` IS NULL", database.RECIPIENT_TABLE)). + Where(fmt.Sprintf("`%s`.`scim_soft_deleted_at` IS NULL", database.RECIPIENT_TABLE)). Where(fmt.Sprintf("`%s`.`%s` = ?", database.RECIPIENT_TABLE, group.FilterField), group.FilterValue) db = whereCompany(db, database.RECIPIENT_TABLE, group.CompanyID) var count int64 @@ -449,6 +484,9 @@ func (rg *RecipientGroup) GetRecipientsByGroupID( if err != nil { return result, errs.Wrap(err) } + if options.ExcludeSoftDeleted { + db = db.Where(fmt.Sprintf("`%s`.`scim_soft_deleted_at` IS NULL", database.RECIPIENT_TABLE)) + } dbRes := db. Model(&database.Recipient{}). Joins("JOIN recipient_group_recipients ON recipient_group_recipients.recipient_id = recipients.id"). @@ -506,6 +544,9 @@ func (rg *RecipientGroup) getDynamicRecipientsByGroup( db = db.Model(&database.Recipient{}). Where(fmt.Sprintf("`%s`.`deleted_at` IS NULL", database.RECIPIENT_TABLE)). Where(fmt.Sprintf("`%s`.`%s` = ?", database.RECIPIENT_TABLE, group.FilterField), group.FilterValue) + if options.ExcludeSoftDeleted { + db = db.Where(fmt.Sprintf("`%s`.`scim_soft_deleted_at` IS NULL", database.RECIPIENT_TABLE)) + } db = whereCompany(db, database.RECIPIENT_TABLE, group.CompanyID) var recipients []database.Recipient diff --git a/backend/seed/migrate.go b/backend/seed/migrate.go index d7d765d6..61c43eaa 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{}, &database.RemoteBrowser{}, &database.ReportTemplate{}, } diff --git a/backend/service/campaign.go b/backend/service/campaign.go index 5745aeb2..e490bee8 100644 --- a/backend/service/campaign.go +++ b/backend/service/campaign.go @@ -347,7 +347,7 @@ func (c *Campaign) schedule( result, err := c.RecipientGroupRepository.GetRecipientsByGroupID( ctx, groupID, - &repository.RecipientOption{}, + &repository.RecipientOption{ExcludeSoftDeleted: true}, ) if err != nil { c.Logger.Errorw("failed to get dynamic group recipients", "error", err) @@ -374,6 +374,16 @@ func (c *Campaign) schedule( } } + // exclude SCIM soft-deleted recipients (pending prune) from targeting + activeRecps := make([]*model.Recipient, 0, len(recps)) + for _, recp := range recps { + if recp.ScimSoftDeletedAt != nil { + continue + } + activeRecps = append(activeRecps, recp) + } + recps = activeRecps + // collect all and remove duplicates for _, recp := range recps { id := recp.ID.MustGet().String() @@ -1299,6 +1309,12 @@ func (c *Campaign) SaveTrackingPixelLoaded( recipientID := campaignRecipient.RecipientID.MustGet() campaignID := campaignRecipient.CampaignID.MustGet() + // do not record events for SCIM-disabled (deprovisioned) recipients + if recipient, rErr := c.RecipientRepository.GetByID(ctx.Request.Context(), &recipientID, &repository.RecipientOption{}); rErr == nil && recipient.ScimSoftDeletedAt != nil { + c.Logger.Debugw("skipping tracking pixel event: recipient is scim-disabled", "campaignRecipientID", campaignRecipientID.String()) + return nil + } + campaign, err := c.CampaignRepository.GetByID( ctx, &campaignID, @@ -4000,6 +4016,11 @@ func (c *Campaign) SendEmailByCampaignRecipientID( return errors.New("recipient is anonymized or deleted") } + // do not send to a SCIM-disabled (deprovisioned) recipient, even manually + if campaignRecipient.Recipient.ScimSoftDeletedAt != nil { + return errors.New("recipient is disabled in the identity provider and cannot be sent to") + } + // check if cancelled if !campaignRecipient.CancelledAt.IsNull() { return errors.New("recipient has been cancelled") diff --git a/backend/service/companyScimConfig.go b/backend/service/companyScimConfig.go new file mode 100644 index 00000000..7155a7df --- /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/domain.go b/backend/service/domain.go index 64322cab..71943b67 100644 --- a/backend/service/domain.go +++ b/backend/service/domain.go @@ -37,6 +37,7 @@ type Domain struct { CertMagicCache *certmagic.Cache DomainRepository *repository.Domain CompanyRepository *repository.Company + OptionRepository *repository.Option CampaignTemplateService *CampaignTemplate AssetService *Asset FileService *File @@ -739,6 +740,23 @@ func (d *Domain) deleteDomain( return validate.WrapErrorWithField(errors.New("proxy domains can only be deleted by deleting the associated proxy configuration"), "domain") } } + + // prevent deletion of the domain currently serving SCIM provisioning. + // a missing option means SCIM is not configured; any other read error fails + // closed so a transient fault cannot let the SCIM domain be deleted. + scimOpt, scimErr := d.OptionRepository.GetByKey(ctx, data.OptionKeyScimDomain) + if scimErr != nil && !errors.Is(scimErr, gorm.ErrRecordNotFound) { + d.Logger.Errorw("failed to check scim domain before deletion", "error", scimErr) + return scimErr + } + if scimErr == nil { + configuredScimDomain := scimOpt.Value.String() + if configuredScimDomain != "" { + if name, nameErr := current.Name.Get(); nameErr == nil && strings.EqualFold(name.String(), configuredScimDomain) { + return validate.WrapErrorWithField(errors.New("this domain is set as the SCIM provisioning domain; change the SCIM domain in settings before deleting it"), "domain") + } + } + } // get the domain domain, err := d.DomainRepository.GetByID( ctx, diff --git a/backend/service/option.go b/backend/service/option.go index 2e301b9a..57816a08 100644 --- a/backend/service/option.go +++ b/backend/service/option.go @@ -20,6 +20,7 @@ import ( type Option struct { Common OptionRepository *repository.Option + DomainRepository *repository.Domain } // GetOption gets an option @@ -398,3 +399,203 @@ func (o *Option) GetObfuscationTemplate(ctx context.Context) (string, error) { } return template, nil } + +// GetScimDomainInternal returns the configured global SCIM domain without any +// authorization check. intended for the phishing-server host gate. returns an +// empty string when SCIM serving is not configured. +func (o *Option) GetScimDomainInternal(ctx context.Context) (string, error) { + opt, err := o.OptionRepository.GetByKey(ctx, data.OptionKeyScimDomain) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return "", nil + } + o.Logger.Errorw("failed to get scim domain option", "error", err) + return "", errs.Wrap(err) + } + return opt.Value.String(), nil +} + +// GetScimDomain returns the configured global SCIM domain (requires global auth). +func (o *Option) GetScimDomain( + ctx context.Context, + session *model.Session, +) (string, error) { + ae := NewAuditEvent("Option.GetScimDomain", session) + isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL) + if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) { + o.LogAuthError(err) + return "", errs.Wrap(err) + } + if !isAuthorized { + o.AuditLogNotAuthorized(ae) + return "", errs.ErrAuthorizationFailed + } + return o.GetScimDomainInternal(ctx) +} + +// SetScimDomain persists the global SCIM domain (requires global auth). An empty +// string disables SCIM serving. A non-empty value must be an existing global +// domain (one not tied to a company). +func (o *Option) SetScimDomain( + ctx context.Context, + session *model.Session, + domain string, +) error { + ae := NewAuditEvent("Option.SetScimDomain", session) + ae.Details["domain"] = domain + isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL) + if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) { + o.LogAuthError(err) + return errs.Wrap(err) + } + if !isAuthorized { + o.AuditLogNotAuthorized(ae) + return errs.ErrAuthorizationFailed + } + // a non-empty value must resolve to an existing global domain + if domain != "" { + nameVO, err := vo.NewString255(domain) + if err != nil { + return errs.NewValidationError(err) + } + d, err := o.DomainRepository.GetByName(ctx, nameVO, &repository.DomainOption{}) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errs.NewValidationError(errors.Errorf("domain %q does not exist", domain)) + } + o.Logger.Errorw("failed to look up scim domain", "error", err) + return errs.Wrap(err) + } + // only global domains (not tied to a company) may be used for SCIM + if d.CompanyID.IsSpecified() && !d.CompanyID.IsNull() { + return errs.NewValidationError(errors.Errorf("domain %q is not a global domain", domain)) + } + // AiTM proxy domains route traffic to a target site and must never serve + // the SCIM provisioning API + if d.ProxyID.IsSpecified() && !d.ProxyID.IsNull() { + return errs.NewValidationError(errors.Errorf("domain %q is a proxy domain and cannot be used for SCIM", domain)) + } + } + if err := o.setScimDomainValue(ctx, domain); err != nil { + return err + } + o.AuditLogAuthorized(ae) + return nil +} + +// setScimDomainValue inserts or updates the single SCIM domain option row. +func (o *Option) setScimDomainValue(ctx context.Context, domain string) error { + valueVO, err := vo.NewOptionalString1MB(domain) + if err != nil { + return errs.NewValidationError(err) + } + opt := &model.Option{ + Key: *vo.NewString127Must(data.OptionKeyScimDomain), + Value: *valueVO, + } + _, getErr := o.OptionRepository.GetByKey(ctx, data.OptionKeyScimDomain) + if getErr != nil { + if !errors.Is(getErr, gorm.ErrRecordNotFound) { + o.Logger.Errorw("failed to check scim domain option existence", "error", getErr) + return errs.Wrap(getErr) + } + if _, insertErr := o.OptionRepository.Insert(ctx, opt); insertErr != nil { + o.Logger.Errorw("failed to insert scim domain option", "error", insertErr) + return errs.Wrap(insertErr) + } + return nil + } + if updateErr := o.OptionRepository.UpdateByKey(ctx, opt); updateErr != nil { + o.Logger.Errorw("failed to update scim domain option", "error", updateErr) + return errs.Wrap(updateErr) + } + return nil +} + +// upsertOptionValue inserts or updates a single option row by key. +func (o *Option) upsertOptionValue(ctx context.Context, key string, value string) error { + valueVO, err := vo.NewOptionalString1MB(value) + if err != nil { + return errs.NewValidationError(err) + } + opt := &model.Option{ + Key: *vo.NewString127Must(key), + Value: *valueVO, + } + _, getErr := o.OptionRepository.GetByKey(ctx, key) + if getErr != nil { + if !errors.Is(getErr, gorm.ErrRecordNotFound) { + return errs.Wrap(getErr) + } + if _, insertErr := o.OptionRepository.Insert(ctx, opt); insertErr != nil { + return errs.Wrap(insertErr) + } + return nil + } + if updateErr := o.OptionRepository.UpdateByKey(ctx, opt); updateErr != nil { + return errs.Wrap(updateErr) + } + return nil +} + +// GetScimSoftDeleteRetentionDaysInternal returns the retention window in days, +// defaulting when unset or invalid. No authorization check. +func (o *Option) GetScimSoftDeleteRetentionDaysInternal(ctx context.Context) (int, error) { + opt, err := o.OptionRepository.GetByKey(ctx, data.OptionKeyScimSoftDeleteRetentionDays) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return data.OptionValueScimSoftDeleteRetentionDaysDefault, nil + } + return 0, errs.Wrap(err) + } + days, convErr := strconv.Atoi(opt.Value.String()) + if convErr != nil || days < 0 { + return data.OptionValueScimSoftDeleteRetentionDaysDefault, nil + } + return days, nil +} + +// GetScimSoftDeleteRetentionDays returns the retention window (requires global auth). +func (o *Option) GetScimSoftDeleteRetentionDays( + ctx context.Context, + session *model.Session, +) (int, error) { + ae := NewAuditEvent("Option.GetScimSoftDeleteRetentionDays", session) + isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL) + if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) { + o.LogAuthError(err) + return 0, errs.Wrap(err) + } + if !isAuthorized { + o.AuditLogNotAuthorized(ae) + return 0, errs.ErrAuthorizationFailed + } + return o.GetScimSoftDeleteRetentionDaysInternal(ctx) +} + +// SetScimSoftDeleteRetentionDays persists the retention window (requires global auth). +func (o *Option) SetScimSoftDeleteRetentionDays( + ctx context.Context, + session *model.Session, + days int, +) error { + ae := NewAuditEvent("Option.SetScimSoftDeleteRetentionDays", session) + ae.Details["days"] = days + isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL) + if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) { + o.LogAuthError(err) + return errs.Wrap(err) + } + if !isAuthorized { + o.AuditLogNotAuthorized(ae) + return errs.ErrAuthorizationFailed + } + if days < 0 { + return errs.NewValidationError(errors.Errorf("retention days must be zero or positive")) + } + if err := o.upsertOptionValue(ctx, data.OptionKeyScimSoftDeleteRetentionDays, strconv.Itoa(days)); err != nil { + return err + } + o.AuditLogAuthorized(ae) + return nil +} diff --git a/backend/service/recipient.go b/backend/service/recipient.go index a5ea9f23..dbfc4a36 100644 --- a/backend/service/recipient.go +++ b/backend/service/recipient.go @@ -714,10 +714,12 @@ func (r *Recipient) deleteRecipientByID( ) return err } - // if the recipient is in any active campaign, cancel the recipient sending - err = r.CampaignRecipientRepository.CancelInActiveCampaigns(ctx, id) + // cancel the recipient's not-yet-sent rows in any open (non-closed) campaign, + // including future-scheduled ones, so anonymization never leaves an uncancelled + // pending row behind + err = r.CampaignRecipientRepository.CancelUnsentInOpenCampaigns(ctx, id) if err != nil { - r.Logger.Errorw("failed to cancel campaign recipient in active campaigns", "error", err) + r.Logger.Errorw("failed to cancel campaign recipient sends before deletion", "error", err) return err } // anonymize all recipient data diff --git a/backend/service/recipientGroup.go b/backend/service/recipientGroup.go index 47e6634c..c1f426ac 100644 --- a/backend/service/recipientGroup.go +++ b/backend/service/recipientGroup.go @@ -254,6 +254,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 00000000..f8eb4794 --- /dev/null +++ b/backend/service/scim.go @@ -0,0 +1,2363 @@ +package service + +import ( + "context" + "fmt" + "strings" + "time" + + "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" + "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"` + // core title attribute — Microsoft Entra maps the directory jobTitle here by + // default (not the enterprise extension), so this is the primary source for Position + Title string `json:"title,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 + RecipientService *Recipient + OptionService *Option + CampaignRepository *repository.Campaign + CampaignRecipientRepository *repository.CampaignRecipient +} + +// 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 — 0 returns zero resources (RFC 7644 §3.4.2.4); a negative or + // absent value means no limit + if count == 0 { + all = []ScimGroup{} + } else 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) + } + s.auditScim("Scim.CreateGroup", config, map[string]any{"groupID": groupID.String()}) + 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) + } + s.auditScim("Scim.ReplaceGroup", config, map[string]any{"groupID": groupID.String()}) + 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) + } + s.auditScim("Scim.PatchGroup", config, map[string]any{"groupID": groupID.String()}) + 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) + } + s.auditScim("Scim.DeleteGroup", config, map[string]any{"groupID": groupID.String()}) + 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) + } + + // load group memberships once so each user can report its groups + groupsByRecipient := map[uuid.UUID][]ScimUserGroup{} + if groupList, gErr := s.RecipientGroupRepository.GetAllByCompanyID(ctx, companyID, &repository.RecipientGroupOption{WithRecipients: true}); gErr != nil { + s.Logger.Warnw("scim list users: failed to load group memberships", "error", gErr) + } else { + groupsByRecipient = buildGroupsByRecipient(groupList) + } + + // 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 rid, idErr := r.ID.Get(); idErr == nil { + u.Groups = groupsByRecipient[rid] + } + 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 — 0 returns zero resources (RFC 7644 §3.4.2.4); a negative or + // absent value means no limit + if count == 0 { + all = []ScimUser{} + } else 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) + u.Groups = s.groupsForRecipient(ctx, companyID, recipientID, 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)) + } + + // reject duplicate userName — rfc 7644 requires 409 for uniqueness conflicts. + // the lookup is case-insensitive so John@X.com and john@x.com collide. + existingByEmail, err := s.RecipientRepository.GetByEmailLowerAndCompanyID(ctx, emailVO, 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 { + // if the existing recipient was SCIM soft-deleted, the IdP is re-provisioning + // the same person — revive and update it instead of returning a conflict + if existingByEmail.ScimSoftDeletedAt != nil { + existingID := existingByEmail.ID.MustGet() + if err := s.RecipientRepository.ClearScimSoftDeleted(ctx, &existingID); err != nil { + return nil, errs.Wrap(err) + } + existingByEmail.ScimSoftDeletedAt = nil + if err := s.applyScimUserToRecipient(ctx, existingByEmail, scimUser); err != nil { + return nil, err + } + if err := s.syncUserGroupMembership(ctx, companyID, &existingID, scimUser.Groups); err != nil { + s.Logger.Warnw("scim create user (revive): failed to sync group membership", "error", err) + } + revived, err := s.RecipientRepository.GetByID(ctx, &existingID, &repository.RecipientOption{}) + if err != nil { + return nil, errs.Wrap(err) + } + s.auditScim("Scim.CreateUser", config, map[string]any{"recipientID": existingID.String(), "revived": true}) + u := recipientToScimUser(revived, baseURL) + return &u, 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) + } + // note: active=false on create is not separately representable — a recipient + // either exists (active) or is deprovisioned (deleted). the resource is still + // created so the IdP receives a retrievable 201 response. + s.auditScim("Scim.CreateUser", config, map[string]any{"recipientID": recipientID.String()}) + u := recipientToScimUser(created, baseURL) + return &u, nil +} + +// deprovisionedUserResponse builds the SCIM body returned after a user has been +// deprovisioned via active=false. Microsoft Entra sends a soft-delete (PATCH +// active=false) and expects a 200 response with the resource showing +// active=false. Returning 404 makes Entra log the disable as a failure and retry +// it every sync cycle, so the recipient is hard-deleted but a success body with +// active=false is returned. +func deprovisionedUserResponse(existing *model.Recipient, baseURL string) *ScimUser { + u := recipientToScimUser(existing, baseURL) + u.Active = false + return &u +} + +// 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) + } + // a PUT with active=false is a deprovision request — hard-delete the recipient + // but return 200 with active=false so the IdP records the disable as a success + if !scimUser.Active { + if err := s.deprovisionRecipient(ctx, recipientID); err != nil { + return nil, errs.Wrap(err) + } + s.auditScim("Scim.DeprovisionUser", config, map[string]any{"recipientID": recipientID.String(), "via": "replace"}) + return deprovisionedUserResponse(existing, baseURL), nil + } + // active=true: revive a previously soft-deleted recipient + if existing.ScimSoftDeletedAt != nil { + if err := s.RecipientRepository.ClearScimSoftDeleted(ctx, recipientID); err != nil { + return nil, errs.Wrap(err) + } + existing.ScimSoftDeletedAt = nil + } + 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) + } + s.auditScim("Scim.ReplaceUser", config, map[string]any{"recipientID": recipientID.String()}) + 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; return 200 with active=false so + // the IdP records the soft-delete as a success instead of retrying + if deactivated { + s.auditScim("Scim.DeprovisionUser", config, map[string]any{"recipientID": recipientID.String(), "via": "patch"}) + return deprovisionedUserResponse(existing, baseURL), nil + } + case "remove": + // remove op on "active" means deactivate — hard-delete the recipient + if strings.EqualFold(op.Path, "active") { + if err := s.deprovisionRecipient(ctx, recipientID); err != nil { + return nil, errs.Wrap(err) + } + s.auditScim("Scim.DeprovisionUser", config, map[string]any{"recipientID": recipientID.String(), "via": "patch"}) + return deprovisionedUserResponse(existing, baseURL), nil + } + } + } + + updated, err := s.RecipientRepository.GetByID(ctx, recipientID, &repository.RecipientOption{}) + if err != nil { + return nil, errs.Wrap(err) + } + s.auditScim("Scim.PatchUser", config, map[string]any{"recipientID": recipientID.String()}) + 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) + } + if err := s.deprovisionRecipient(ctx, recipientID); err != nil { + return errs.Wrap(err) + } + s.auditScim("Scim.DeprovisionUser", config, map[string]any{"recipientID": recipientID.String(), "via": "delete"}) + return nil +} + +// 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 ─────────────────────────────────────────────────────────────────── + +// deprovisionRecipient removes a recipient from all groups and hard-deletes it. +// shared by DELETE, PUT active=false and PATCH active=false. +func (s *Scim) deprovisionRecipient(ctx context.Context, recipientID *uuid.UUID) error { + // mark the recipient as SCIM soft-deleted; the anonymizing delete runs only + // after the retention grace period (scheduled job or on-demand prune) + if err := s.RecipientRepository.MarkScimSoftDeleted(ctx, recipientID, time.Now()); err != nil { + return err + } + // cancel pending sends in active campaigns so no email reaches the disabled + // recipient; the rows are kept (cancelled, not deleted) so stats and any + // already-sent tracking links stay consistent + return s.CampaignRecipientRepository.CancelInActiveCampaigns(ctx, recipientID) +} + +// reviveIfSoftDeleted clears the soft-delete mark when the IdP re-activates a +// recipient. It is a no-op when the recipient is not soft-deleted. +func (s *Scim) reviveIfSoftDeleted(ctx context.Context, existing *model.Recipient, recipientID *uuid.UUID) error { + if existing.ScimSoftDeletedAt == nil { + return nil + } + if err := s.RecipientRepository.ClearScimSoftDeleted(ctx, recipientID); err != nil { + return err + } + existing.ScimSoftDeletedAt = nil + return nil +} + +// pruneSoftDeleted runs the anonymizing delete for SCIM-disabled recipients whose +// scim_soft_deleted_at is before the given cutoff. A nil companyID covers all +// companies. No authorization check — callers are responsible for that. +func (s *Scim) pruneSoftDeleted(ctx context.Context, companyID *uuid.UUID, before time.Time) (int, error) { + recipients, err := s.RecipientRepository.GetScimSoftDeletedBefore(ctx, companyID, before) + if err != nil { + return 0, errs.Wrap(err) + } + pruned := 0 + for _, r := range recipients { + id, idErr := r.ID.Get() + if idErr != nil { + continue + } + if delErr := s.RecipientService.deleteRecipientByID(ctx, &id); delErr != nil { + s.Logger.Errorw("scim prune: failed to delete soft-deleted recipient", "error", delErr, "recipientID", id.String()) + continue + } + pruned++ + } + return pruned, nil +} + +// PruneExpiredSoftDeleted prunes disabled recipients across all companies whose +// retention window has elapsed. Used by the scheduled system task. +func (s *Scim) PruneExpiredSoftDeleted(ctx context.Context, session *model.Session) (int, error) { + ae := NewAuditEvent("Scim.PruneExpiredSoftDeleted", session) + isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL) + if err != nil { + s.LogAuthError(err) + return 0, errs.Wrap(err) + } + if !isAuthorized { + s.AuditLogNotAuthorized(ae) + return 0, errs.ErrAuthorizationFailed + } + days, err := s.OptionService.GetScimSoftDeleteRetentionDaysInternal(ctx) + if err != nil { + return 0, errs.Wrap(err) + } + cutoff := time.Now().Add(-time.Duration(days) * 24 * time.Hour) + pruned, err := s.pruneSoftDeleted(ctx, nil, cutoff) + if err != nil { + return 0, err + } + if pruned > 0 { + ae.Details["pruned"] = pruned + s.AuditLogAuthorized(ae) + } + return pruned, nil +} + +// PruneSoftDeletedAuthorized is the admin (session-authenticated) on-demand prune +// for a company. Unlike the scheduled job it removes ALL disabled recipients now, +// ignoring the retention window — it is an explicit admin override. +func (s *Scim) PruneSoftDeletedAuthorized( + ctx context.Context, + session *model.Session, + companyID *uuid.UUID, +) (int, error) { + ae := NewAuditEvent("Scim.PruneSoftDeletedAuthorized", session) + if companyID != nil { + ae.Details["companyID"] = companyID.String() + } + isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL) + if err != nil { + s.LogAuthError(err) + return 0, errs.Wrap(err) + } + if !isAuthorized { + s.AuditLogNotAuthorized(ae) + return 0, errs.ErrAuthorizationFailed + } + pruned, err := s.pruneSoftDeleted(ctx, companyID, time.Now()) + if err != nil { + return 0, err + } + ae.Details["pruned"] = pruned + s.AuditLogAuthorized(ae) + return pruned, nil +} + +// auditScim emits an audit event for an externally driven SCIM mutation. +// SCIM has no admin session, so the actor is identified by the company and the +// token prefix instead of a user id. +func (s *Scim) auditScim(name string, config *model.CompanyScimConfig, details map[string]any) { + ae := NewAuditEvent(name, nil) + ae.Details["actor"] = "scim" + if config != nil { + if cid, err := config.CompanyID.Get(); err == nil { + ae.Details["companyID"] = cid.String() + } + if tp, err := config.TokenPrefix.Get(); err == nil { + ae.Details["scimTokenPrefix"] = tp + } + } + for k, v := range details { + ae.Details[k] = v + } + s.AuditLogAuthorized(ae) +} + +// 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 { + // PUT is a full replace (RFC 7644 §3.5.1): attributes absent from the + // request are cleared. email is the one exception — it is required, so an + // absent or invalid email leaves the existing address untouched. + existing.ScimUserName.Set(*vo.NewOptionalString127Must(truncate(scimUserNameFrom(scimUser), 127))) + // email — stored lowercased for case-insensitive matching + if email, err := canonicalEmailLower(scimUser); err == nil && email != "" { + if ev, err := vo.NewEmail(email); err == nil { + existing.Email.Set(*ev) + } + } + // first and last name + existing.FirstName.Set(*vo.NewOptionalString127Must(truncate(firstNameFrom(scimUser), 127))) + existing.LastName.Set(*vo.NewOptionalString127Must(truncate(lastNameFrom(scimUser), 127))) + // phone + existing.Phone.Set(*vo.NewOptionalString127Must(truncate(primaryPhoneFrom(scimUser), 127))) + // department from the enterprise extension; job title from the core title + // attribute (where Entra puts it by default) with the enterprise title as fallback + department := "" + if scimUser.EnterpriseUser != nil { + department = scimUser.EnterpriseUser.Department + } + existing.Department.Set(*vo.NewOptionalString127Must(truncate(department, 127))) + existing.Position.Set(*vo.NewOptionalString127Must(truncate(jobTitleFrom(scimUser), 127))) + // addresses — city and country from primary/work address + city, country := primaryAddressFrom(scimUser) + existing.City.Set(*vo.NewOptionalString127Must(truncate(city, 127))) + existing.Country.Set(*vo.NewOptionalString127Must(truncate(country, 127))) + // externalId -> extra_identifier + existing.ExtraIdentifier.Set(*vo.NewOptionalString127Must(truncate(scimUser.ExternalID, 127))) + // misc from custom extension + misc := "" + if scimUser.CustomExtension != nil { + misc = scimUser.CustomExtension.Misc + } + existing.Misc.Set(*vo.NewOptionalString127Must(truncate(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 deprovision the recipient + if path == "active" { + active := boolFromPatchValue(op.Value) + if !active { + return true, s.deprovisionRecipient(ctx, recipientID) + } + // active=true revives a soft-deleted recipient; otherwise a no-op + return false, s.reviveIfSoftDeleted(ctx, existing, recipientID) + } + + // 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) { + return true, s.deprovisionRecipient(ctx, recipientID) + } + // active=true in the map revives a soft-deleted recipient + if rawActive, ok := m["active"]; ok && boolFromPatchValue(rawActive) { + if err := s.reviveIfSoftDeleted(ctx, existing, recipientID); err != nil { + return false, err + } + } + 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(strings.ToLower(strings.TrimSpace(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 "title", "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 "title", "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, + Title: pos, + Emails: emails, + PhoneNumbers: phones, + EnterpriseUser: enterprise, + Addresses: addresses, + Active: r.ScimSoftDeletedAt == nil, + ExternalID: externalID, + CustomExtension: custom, + } + if baseURL != "" && id != "" { + u.Meta = &ScimMeta{ + ResourceType: scimResourceTypeUser, + Location: baseURL + "/Users/" + id, + } + } + return u +} + +// jobTitleFrom returns the user's job title. Microsoft Entra maps the directory +// jobTitle to the core SCIM "title" attribute by default, so that is preferred; +// the enterprise extension title is used as a fallback for IdPs that send it there. +func jobTitleFrom(u *ScimUser) string { + if u.Title != "" { + return u.Title + } + if u.EnterpriseUser != nil { + return u.EnterpriseUser.Title + } + return "" +} + +// 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))) + + // email is stored lowercased so dedup and the unique index are case + // insensitive; the original userName case is preserved in scim_user_name + emailStr, _ := canonicalEmailLower(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("")) + } + + department := "" + if scimUser.EnterpriseUser != nil { + department = scimUser.EnterpriseUser.Department + } + r.Department = nullable.NewNullableWithValue(*vo.NewOptionalString127Must(truncate(department, 127))) + r.Position = nullable.NewNullableWithValue(*vo.NewOptionalString127Must(truncate(jobTitleFrom(scimUser), 127))) + + // 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/backend/task/runner.go b/backend/task/runner.go index 1649355a..62535b32 100644 --- a/backend/task/runner.go +++ b/backend/task/runner.go @@ -28,6 +28,7 @@ type Runner struct { MicrosoftDeviceCode *service.MicrosoftDeviceCode OptionService *service.Option RecipientService *service.Recipient + ScimService *service.Scim Logger *zap.SugaredLogger } @@ -195,6 +196,10 @@ func (d *Runner) ProcessSystemTasks( d.runTask("system - prune orphaned recipients", func() error { return d.PruneOrphanedRecipients(ctx, session) }) + d.runTask("system - prune scim disabled recipients", func() error { + _, err := d.ScimService.PruneExpiredSoftDeleted(ctx, session) + return err + }) d.runTask("system - late schedule campaigns", func() error { return d.CampaignService.SchedulePendingCampaigns(ctx, session) }) diff --git a/backend/vendor/golang.org/x/crypto/bcrypt/base64.go b/backend/vendor/golang.org/x/crypto/bcrypt/base64.go new file mode 100644 index 00000000..fc311609 --- /dev/null +++ b/backend/vendor/golang.org/x/crypto/bcrypt/base64.go @@ -0,0 +1,35 @@ +// Copyright 2011 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package bcrypt + +import "encoding/base64" + +const alphabet = "./ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" + +var bcEncoding = base64.NewEncoding(alphabet) + +func base64Encode(src []byte) []byte { + n := bcEncoding.EncodedLen(len(src)) + dst := make([]byte, n) + bcEncoding.Encode(dst, src) + for dst[n-1] == '=' { + n-- + } + return dst[:n] +} + +func base64Decode(src []byte) ([]byte, error) { + numOfEquals := 4 - (len(src) % 4) + for i := 0; i < numOfEquals; i++ { + src = append(src, '=') + } + + dst := make([]byte, bcEncoding.DecodedLen(len(src))) + n, err := bcEncoding.Decode(dst, src) + if err != nil { + return nil, err + } + return dst[:n], nil +} diff --git a/backend/vendor/golang.org/x/crypto/bcrypt/bcrypt.go b/backend/vendor/golang.org/x/crypto/bcrypt/bcrypt.go new file mode 100644 index 00000000..3e7f8df8 --- /dev/null +++ b/backend/vendor/golang.org/x/crypto/bcrypt/bcrypt.go @@ -0,0 +1,304 @@ +// Copyright 2011 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Package bcrypt implements Provos and Mazières's bcrypt adaptive hashing +// algorithm. See http://www.usenix.org/event/usenix99/provos/provos.pdf +package bcrypt + +// The code is a port of Provos and Mazières's C implementation. +import ( + "crypto/rand" + "crypto/subtle" + "errors" + "fmt" + "io" + "strconv" + + "golang.org/x/crypto/blowfish" +) + +const ( + MinCost int = 4 // the minimum allowable cost as passed in to GenerateFromPassword + MaxCost int = 31 // the maximum allowable cost as passed in to GenerateFromPassword + DefaultCost int = 10 // the cost that will actually be set if a cost below MinCost is passed into GenerateFromPassword +) + +// The error returned from CompareHashAndPassword when a password and hash do +// not match. +var ErrMismatchedHashAndPassword = errors.New("crypto/bcrypt: hashedPassword is not the hash of the given password") + +// The error returned from CompareHashAndPassword when a hash is too short to +// be a bcrypt hash. +var ErrHashTooShort = errors.New("crypto/bcrypt: hashedSecret too short to be a bcrypted password") + +// The error returned from CompareHashAndPassword when a hash was created with +// a bcrypt algorithm newer than this implementation. +type HashVersionTooNewError byte + +func (hv HashVersionTooNewError) Error() string { + return fmt.Sprintf("crypto/bcrypt: bcrypt algorithm version '%c' requested is newer than current version '%c'", byte(hv), majorVersion) +} + +// The error returned from CompareHashAndPassword when a hash starts with something other than '$' +type InvalidHashPrefixError byte + +func (ih InvalidHashPrefixError) Error() string { + return fmt.Sprintf("crypto/bcrypt: bcrypt hashes must start with '$', but hashedSecret started with '%c'", byte(ih)) +} + +type InvalidCostError int + +func (ic InvalidCostError) Error() string { + return fmt.Sprintf("crypto/bcrypt: cost %d is outside allowed inclusive range %d..%d", int(ic), MinCost, MaxCost) +} + +const ( + majorVersion = '2' + minorVersion = 'a' + maxSaltSize = 16 + maxCryptedHashSize = 23 + encodedSaltSize = 22 + encodedHashSize = 31 + minHashSize = 59 +) + +// magicCipherData is an IV for the 64 Blowfish encryption calls in +// bcrypt(). It's the string "OrpheanBeholderScryDoubt" in big-endian bytes. +var magicCipherData = []byte{ + 0x4f, 0x72, 0x70, 0x68, + 0x65, 0x61, 0x6e, 0x42, + 0x65, 0x68, 0x6f, 0x6c, + 0x64, 0x65, 0x72, 0x53, + 0x63, 0x72, 0x79, 0x44, + 0x6f, 0x75, 0x62, 0x74, +} + +type hashed struct { + hash []byte + salt []byte + cost int // allowed range is MinCost to MaxCost + major byte + minor byte +} + +// ErrPasswordTooLong is returned when the password passed to +// GenerateFromPassword is too long (i.e. > 72 bytes). +var ErrPasswordTooLong = errors.New("bcrypt: password length exceeds 72 bytes") + +// GenerateFromPassword returns the bcrypt hash of the password at the given +// cost. If the cost given is less than MinCost, the cost will be set to +// DefaultCost, instead. Use CompareHashAndPassword, as defined in this package, +// to compare the returned hashed password with its cleartext version. +// GenerateFromPassword does not accept passwords longer than 72 bytes, which +// is the longest password bcrypt will operate on. +func GenerateFromPassword(password []byte, cost int) ([]byte, error) { + if len(password) > 72 { + return nil, ErrPasswordTooLong + } + p, err := newFromPassword(password, cost) + if err != nil { + return nil, err + } + return p.Hash(), nil +} + +// CompareHashAndPassword compares a bcrypt hashed password with its possible +// plaintext equivalent. Returns nil on success, or an error on failure. +func CompareHashAndPassword(hashedPassword, password []byte) error { + p, err := newFromHash(hashedPassword) + if err != nil { + return err + } + + otherHash, err := bcrypt(password, p.cost, p.salt) + if err != nil { + return err + } + + otherP := &hashed{otherHash, p.salt, p.cost, p.major, p.minor} + if subtle.ConstantTimeCompare(p.Hash(), otherP.Hash()) == 1 { + return nil + } + + return ErrMismatchedHashAndPassword +} + +// Cost returns the hashing cost used to create the given hashed +// password. When, in the future, the hashing cost of a password system needs +// to be increased in order to adjust for greater computational power, this +// function allows one to establish which passwords need to be updated. +func Cost(hashedPassword []byte) (int, error) { + p, err := newFromHash(hashedPassword) + if err != nil { + return 0, err + } + return p.cost, nil +} + +func newFromPassword(password []byte, cost int) (*hashed, error) { + if cost < MinCost { + cost = DefaultCost + } + p := new(hashed) + p.major = majorVersion + p.minor = minorVersion + + err := checkCost(cost) + if err != nil { + return nil, err + } + p.cost = cost + + unencodedSalt := make([]byte, maxSaltSize) + _, err = io.ReadFull(rand.Reader, unencodedSalt) + if err != nil { + return nil, err + } + + p.salt = base64Encode(unencodedSalt) + hash, err := bcrypt(password, p.cost, p.salt) + if err != nil { + return nil, err + } + p.hash = hash + return p, err +} + +func newFromHash(hashedSecret []byte) (*hashed, error) { + if len(hashedSecret) < minHashSize { + return nil, ErrHashTooShort + } + p := new(hashed) + n, err := p.decodeVersion(hashedSecret) + if err != nil { + return nil, err + } + hashedSecret = hashedSecret[n:] + n, err = p.decodeCost(hashedSecret) + if err != nil { + return nil, err + } + hashedSecret = hashedSecret[n:] + + // The "+2" is here because we'll have to append at most 2 '=' to the salt + // when base64 decoding it in expensiveBlowfishSetup(). + p.salt = make([]byte, encodedSaltSize, encodedSaltSize+2) + copy(p.salt, hashedSecret[:encodedSaltSize]) + + hashedSecret = hashedSecret[encodedSaltSize:] + p.hash = make([]byte, len(hashedSecret)) + copy(p.hash, hashedSecret) + + return p, nil +} + +func bcrypt(password []byte, cost int, salt []byte) ([]byte, error) { + cipherData := make([]byte, len(magicCipherData)) + copy(cipherData, magicCipherData) + + c, err := expensiveBlowfishSetup(password, uint32(cost), salt) + if err != nil { + return nil, err + } + + for i := 0; i < 24; i += 8 { + for j := 0; j < 64; j++ { + c.Encrypt(cipherData[i:i+8], cipherData[i:i+8]) + } + } + + // Bug compatibility with C bcrypt implementations. We only encode 23 of + // the 24 bytes encrypted. + hsh := base64Encode(cipherData[:maxCryptedHashSize]) + return hsh, nil +} + +func expensiveBlowfishSetup(key []byte, cost uint32, salt []byte) (*blowfish.Cipher, error) { + csalt, err := base64Decode(salt) + if err != nil { + return nil, err + } + + // Bug compatibility with C bcrypt implementations. They use the trailing + // NULL in the key string during expansion. + // We copy the key to prevent changing the underlying array. + ckey := append(key[:len(key):len(key)], 0) + + c, err := blowfish.NewSaltedCipher(ckey, csalt) + if err != nil { + return nil, err + } + + var i, rounds uint64 + rounds = 1 << cost + for i = 0; i < rounds; i++ { + blowfish.ExpandKey(ckey, c) + blowfish.ExpandKey(csalt, c) + } + + return c, nil +} + +func (p *hashed) Hash() []byte { + arr := make([]byte, 60) + arr[0] = '$' + arr[1] = p.major + n := 2 + if p.minor != 0 { + arr[2] = p.minor + n = 3 + } + arr[n] = '$' + n++ + copy(arr[n:], []byte(fmt.Sprintf("%02d", p.cost))) + n += 2 + arr[n] = '$' + n++ + copy(arr[n:], p.salt) + n += encodedSaltSize + copy(arr[n:], p.hash) + n += encodedHashSize + return arr[:n] +} + +func (p *hashed) decodeVersion(sbytes []byte) (int, error) { + if sbytes[0] != '$' { + return -1, InvalidHashPrefixError(sbytes[0]) + } + if sbytes[1] > majorVersion { + return -1, HashVersionTooNewError(sbytes[1]) + } + p.major = sbytes[1] + n := 3 + if sbytes[2] != '$' { + p.minor = sbytes[2] + n++ + } + return n, nil +} + +// sbytes should begin where decodeVersion left off. +func (p *hashed) decodeCost(sbytes []byte) (int, error) { + cost, err := strconv.Atoi(string(sbytes[0:2])) + if err != nil { + return -1, err + } + err = checkCost(cost) + if err != nil { + return -1, err + } + p.cost = cost + return 3, nil +} + +func (p *hashed) String() string { + return fmt.Sprintf("&{hash: %#v, salt: %#v, cost: %d, major: %c, minor: %c}", string(p.hash), p.salt, p.cost, p.major, p.minor) +} + +func checkCost(cost int) error { + if cost < MinCost || cost > MaxCost { + return InvalidCostError(cost) + } + return nil +} diff --git a/backend/vendor/golang.org/x/crypto/blowfish/block.go b/backend/vendor/golang.org/x/crypto/blowfish/block.go new file mode 100644 index 00000000..9d80f195 --- /dev/null +++ b/backend/vendor/golang.org/x/crypto/blowfish/block.go @@ -0,0 +1,159 @@ +// Copyright 2010 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package blowfish + +// getNextWord returns the next big-endian uint32 value from the byte slice +// at the given position in a circular manner, updating the position. +func getNextWord(b []byte, pos *int) uint32 { + var w uint32 + j := *pos + for i := 0; i < 4; i++ { + w = w<<8 | uint32(b[j]) + j++ + if j >= len(b) { + j = 0 + } + } + *pos = j + return w +} + +// ExpandKey performs a key expansion on the given *Cipher. Specifically, it +// performs the Blowfish algorithm's key schedule which sets up the *Cipher's +// pi and substitution tables for calls to Encrypt. This is used, primarily, +// by the bcrypt package to reuse the Blowfish key schedule during its +// set up. It's unlikely that you need to use this directly. +func ExpandKey(key []byte, c *Cipher) { + j := 0 + for i := 0; i < 18; i++ { + // Using inlined getNextWord for performance. + var d uint32 + for k := 0; k < 4; k++ { + d = d<<8 | uint32(key[j]) + j++ + if j >= len(key) { + j = 0 + } + } + c.p[i] ^= d + } + + var l, r uint32 + for i := 0; i < 18; i += 2 { + l, r = encryptBlock(l, r, c) + c.p[i], c.p[i+1] = l, r + } + + for i := 0; i < 256; i += 2 { + l, r = encryptBlock(l, r, c) + c.s0[i], c.s0[i+1] = l, r + } + for i := 0; i < 256; i += 2 { + l, r = encryptBlock(l, r, c) + c.s1[i], c.s1[i+1] = l, r + } + for i := 0; i < 256; i += 2 { + l, r = encryptBlock(l, r, c) + c.s2[i], c.s2[i+1] = l, r + } + for i := 0; i < 256; i += 2 { + l, r = encryptBlock(l, r, c) + c.s3[i], c.s3[i+1] = l, r + } +} + +// This is similar to ExpandKey, but folds the salt during the key +// schedule. While ExpandKey is essentially expandKeyWithSalt with an all-zero +// salt passed in, reusing ExpandKey turns out to be a place of inefficiency +// and specializing it here is useful. +func expandKeyWithSalt(key []byte, salt []byte, c *Cipher) { + j := 0 + for i := 0; i < 18; i++ { + c.p[i] ^= getNextWord(key, &j) + } + + j = 0 + var l, r uint32 + for i := 0; i < 18; i += 2 { + l ^= getNextWord(salt, &j) + r ^= getNextWord(salt, &j) + l, r = encryptBlock(l, r, c) + c.p[i], c.p[i+1] = l, r + } + + for i := 0; i < 256; i += 2 { + l ^= getNextWord(salt, &j) + r ^= getNextWord(salt, &j) + l, r = encryptBlock(l, r, c) + c.s0[i], c.s0[i+1] = l, r + } + + for i := 0; i < 256; i += 2 { + l ^= getNextWord(salt, &j) + r ^= getNextWord(salt, &j) + l, r = encryptBlock(l, r, c) + c.s1[i], c.s1[i+1] = l, r + } + + for i := 0; i < 256; i += 2 { + l ^= getNextWord(salt, &j) + r ^= getNextWord(salt, &j) + l, r = encryptBlock(l, r, c) + c.s2[i], c.s2[i+1] = l, r + } + + for i := 0; i < 256; i += 2 { + l ^= getNextWord(salt, &j) + r ^= getNextWord(salt, &j) + l, r = encryptBlock(l, r, c) + c.s3[i], c.s3[i+1] = l, r + } +} + +func encryptBlock(l, r uint32, c *Cipher) (uint32, uint32) { + xl, xr := l, r + xl ^= c.p[0] + xr ^= ((c.s0[byte(xl>>24)] + c.s1[byte(xl>>16)]) ^ c.s2[byte(xl>>8)]) + c.s3[byte(xl)] ^ c.p[1] + xl ^= ((c.s0[byte(xr>>24)] + c.s1[byte(xr>>16)]) ^ c.s2[byte(xr>>8)]) + c.s3[byte(xr)] ^ c.p[2] + xr ^= ((c.s0[byte(xl>>24)] + c.s1[byte(xl>>16)]) ^ c.s2[byte(xl>>8)]) + c.s3[byte(xl)] ^ c.p[3] + xl ^= ((c.s0[byte(xr>>24)] + c.s1[byte(xr>>16)]) ^ c.s2[byte(xr>>8)]) + c.s3[byte(xr)] ^ c.p[4] + xr ^= ((c.s0[byte(xl>>24)] + c.s1[byte(xl>>16)]) ^ c.s2[byte(xl>>8)]) + c.s3[byte(xl)] ^ c.p[5] + xl ^= ((c.s0[byte(xr>>24)] + c.s1[byte(xr>>16)]) ^ c.s2[byte(xr>>8)]) + c.s3[byte(xr)] ^ c.p[6] + xr ^= ((c.s0[byte(xl>>24)] + c.s1[byte(xl>>16)]) ^ c.s2[byte(xl>>8)]) + c.s3[byte(xl)] ^ c.p[7] + xl ^= ((c.s0[byte(xr>>24)] + c.s1[byte(xr>>16)]) ^ c.s2[byte(xr>>8)]) + c.s3[byte(xr)] ^ c.p[8] + xr ^= ((c.s0[byte(xl>>24)] + c.s1[byte(xl>>16)]) ^ c.s2[byte(xl>>8)]) + c.s3[byte(xl)] ^ c.p[9] + xl ^= ((c.s0[byte(xr>>24)] + c.s1[byte(xr>>16)]) ^ c.s2[byte(xr>>8)]) + c.s3[byte(xr)] ^ c.p[10] + xr ^= ((c.s0[byte(xl>>24)] + c.s1[byte(xl>>16)]) ^ c.s2[byte(xl>>8)]) + c.s3[byte(xl)] ^ c.p[11] + xl ^= ((c.s0[byte(xr>>24)] + c.s1[byte(xr>>16)]) ^ c.s2[byte(xr>>8)]) + c.s3[byte(xr)] ^ c.p[12] + xr ^= ((c.s0[byte(xl>>24)] + c.s1[byte(xl>>16)]) ^ c.s2[byte(xl>>8)]) + c.s3[byte(xl)] ^ c.p[13] + xl ^= ((c.s0[byte(xr>>24)] + c.s1[byte(xr>>16)]) ^ c.s2[byte(xr>>8)]) + c.s3[byte(xr)] ^ c.p[14] + xr ^= ((c.s0[byte(xl>>24)] + c.s1[byte(xl>>16)]) ^ c.s2[byte(xl>>8)]) + c.s3[byte(xl)] ^ c.p[15] + xl ^= ((c.s0[byte(xr>>24)] + c.s1[byte(xr>>16)]) ^ c.s2[byte(xr>>8)]) + c.s3[byte(xr)] ^ c.p[16] + xr ^= c.p[17] + return xr, xl +} + +func decryptBlock(l, r uint32, c *Cipher) (uint32, uint32) { + xl, xr := l, r + xl ^= c.p[17] + xr ^= ((c.s0[byte(xl>>24)] + c.s1[byte(xl>>16)]) ^ c.s2[byte(xl>>8)]) + c.s3[byte(xl)] ^ c.p[16] + xl ^= ((c.s0[byte(xr>>24)] + c.s1[byte(xr>>16)]) ^ c.s2[byte(xr>>8)]) + c.s3[byte(xr)] ^ c.p[15] + xr ^= ((c.s0[byte(xl>>24)] + c.s1[byte(xl>>16)]) ^ c.s2[byte(xl>>8)]) + c.s3[byte(xl)] ^ c.p[14] + xl ^= ((c.s0[byte(xr>>24)] + c.s1[byte(xr>>16)]) ^ c.s2[byte(xr>>8)]) + c.s3[byte(xr)] ^ c.p[13] + xr ^= ((c.s0[byte(xl>>24)] + c.s1[byte(xl>>16)]) ^ c.s2[byte(xl>>8)]) + c.s3[byte(xl)] ^ c.p[12] + xl ^= ((c.s0[byte(xr>>24)] + c.s1[byte(xr>>16)]) ^ c.s2[byte(xr>>8)]) + c.s3[byte(xr)] ^ c.p[11] + xr ^= ((c.s0[byte(xl>>24)] + c.s1[byte(xl>>16)]) ^ c.s2[byte(xl>>8)]) + c.s3[byte(xl)] ^ c.p[10] + xl ^= ((c.s0[byte(xr>>24)] + c.s1[byte(xr>>16)]) ^ c.s2[byte(xr>>8)]) + c.s3[byte(xr)] ^ c.p[9] + xr ^= ((c.s0[byte(xl>>24)] + c.s1[byte(xl>>16)]) ^ c.s2[byte(xl>>8)]) + c.s3[byte(xl)] ^ c.p[8] + xl ^= ((c.s0[byte(xr>>24)] + c.s1[byte(xr>>16)]) ^ c.s2[byte(xr>>8)]) + c.s3[byte(xr)] ^ c.p[7] + xr ^= ((c.s0[byte(xl>>24)] + c.s1[byte(xl>>16)]) ^ c.s2[byte(xl>>8)]) + c.s3[byte(xl)] ^ c.p[6] + xl ^= ((c.s0[byte(xr>>24)] + c.s1[byte(xr>>16)]) ^ c.s2[byte(xr>>8)]) + c.s3[byte(xr)] ^ c.p[5] + xr ^= ((c.s0[byte(xl>>24)] + c.s1[byte(xl>>16)]) ^ c.s2[byte(xl>>8)]) + c.s3[byte(xl)] ^ c.p[4] + xl ^= ((c.s0[byte(xr>>24)] + c.s1[byte(xr>>16)]) ^ c.s2[byte(xr>>8)]) + c.s3[byte(xr)] ^ c.p[3] + xr ^= ((c.s0[byte(xl>>24)] + c.s1[byte(xl>>16)]) ^ c.s2[byte(xl>>8)]) + c.s3[byte(xl)] ^ c.p[2] + xl ^= ((c.s0[byte(xr>>24)] + c.s1[byte(xr>>16)]) ^ c.s2[byte(xr>>8)]) + c.s3[byte(xr)] ^ c.p[1] + xr ^= c.p[0] + return xr, xl +} diff --git a/backend/vendor/golang.org/x/crypto/blowfish/cipher.go b/backend/vendor/golang.org/x/crypto/blowfish/cipher.go new file mode 100644 index 00000000..08989568 --- /dev/null +++ b/backend/vendor/golang.org/x/crypto/blowfish/cipher.go @@ -0,0 +1,99 @@ +// Copyright 2010 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Package blowfish implements Bruce Schneier's Blowfish encryption algorithm. +// +// Blowfish is a legacy cipher and its short block size makes it vulnerable to +// birthday bound attacks (see https://sweet32.info). It should only be used +// where compatibility with legacy systems, not security, is the goal. +// +// Deprecated: any new system should use AES (from crypto/aes, if necessary in +// an AEAD mode like crypto/cipher.NewGCM) or XChaCha20-Poly1305 (from +// golang.org/x/crypto/chacha20poly1305). +package blowfish + +// The code is a port of Bruce Schneier's C implementation. +// See https://www.schneier.com/blowfish.html. + +import "strconv" + +// The Blowfish block size in bytes. +const BlockSize = 8 + +// A Cipher is an instance of Blowfish encryption using a particular key. +type Cipher struct { + p [18]uint32 + s0, s1, s2, s3 [256]uint32 +} + +type KeySizeError int + +func (k KeySizeError) Error() string { + return "crypto/blowfish: invalid key size " + strconv.Itoa(int(k)) +} + +// NewCipher creates and returns a Cipher. +// The key argument should be the Blowfish key, from 1 to 56 bytes. +func NewCipher(key []byte) (*Cipher, error) { + var result Cipher + if k := len(key); k < 1 || k > 56 { + return nil, KeySizeError(k) + } + initCipher(&result) + ExpandKey(key, &result) + return &result, nil +} + +// NewSaltedCipher creates a returns a Cipher that folds a salt into its key +// schedule. For most purposes, NewCipher, instead of NewSaltedCipher, is +// sufficient and desirable. For bcrypt compatibility, the key can be over 56 +// bytes. +func NewSaltedCipher(key, salt []byte) (*Cipher, error) { + if len(salt) == 0 { + return NewCipher(key) + } + var result Cipher + if k := len(key); k < 1 { + return nil, KeySizeError(k) + } + initCipher(&result) + expandKeyWithSalt(key, salt, &result) + return &result, nil +} + +// BlockSize returns the Blowfish block size, 8 bytes. +// It is necessary to satisfy the Block interface in the +// package "crypto/cipher". +func (c *Cipher) BlockSize() int { return BlockSize } + +// Encrypt encrypts the 8-byte buffer src using the key k +// and stores the result in dst. +// Note that for amounts of data larger than a block, +// it is not safe to just call Encrypt on successive blocks; +// instead, use an encryption mode like CBC (see crypto/cipher/cbc.go). +func (c *Cipher) Encrypt(dst, src []byte) { + l := uint32(src[0])<<24 | uint32(src[1])<<16 | uint32(src[2])<<8 | uint32(src[3]) + r := uint32(src[4])<<24 | uint32(src[5])<<16 | uint32(src[6])<<8 | uint32(src[7]) + l, r = encryptBlock(l, r, c) + dst[0], dst[1], dst[2], dst[3] = byte(l>>24), byte(l>>16), byte(l>>8), byte(l) + dst[4], dst[5], dst[6], dst[7] = byte(r>>24), byte(r>>16), byte(r>>8), byte(r) +} + +// Decrypt decrypts the 8-byte buffer src using the key k +// and stores the result in dst. +func (c *Cipher) Decrypt(dst, src []byte) { + l := uint32(src[0])<<24 | uint32(src[1])<<16 | uint32(src[2])<<8 | uint32(src[3]) + r := uint32(src[4])<<24 | uint32(src[5])<<16 | uint32(src[6])<<8 | uint32(src[7]) + l, r = decryptBlock(l, r, c) + dst[0], dst[1], dst[2], dst[3] = byte(l>>24), byte(l>>16), byte(l>>8), byte(l) + dst[4], dst[5], dst[6], dst[7] = byte(r>>24), byte(r>>16), byte(r>>8), byte(r) +} + +func initCipher(c *Cipher) { + copy(c.p[0:], p[0:]) + copy(c.s0[0:], s0[0:]) + copy(c.s1[0:], s1[0:]) + copy(c.s2[0:], s2[0:]) + copy(c.s3[0:], s3[0:]) +} diff --git a/backend/vendor/golang.org/x/crypto/blowfish/const.go b/backend/vendor/golang.org/x/crypto/blowfish/const.go new file mode 100644 index 00000000..d0407759 --- /dev/null +++ b/backend/vendor/golang.org/x/crypto/blowfish/const.go @@ -0,0 +1,199 @@ +// Copyright 2010 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// The startup permutation array and substitution boxes. +// They are the hexadecimal digits of PI; see: +// https://www.schneier.com/code/constants.txt. + +package blowfish + +var s0 = [256]uint32{ + 0xd1310ba6, 0x98dfb5ac, 0x2ffd72db, 0xd01adfb7, 0xb8e1afed, 0x6a267e96, + 0xba7c9045, 0xf12c7f99, 0x24a19947, 0xb3916cf7, 0x0801f2e2, 0x858efc16, + 0x636920d8, 0x71574e69, 0xa458fea3, 0xf4933d7e, 0x0d95748f, 0x728eb658, + 0x718bcd58, 0x82154aee, 0x7b54a41d, 0xc25a59b5, 0x9c30d539, 0x2af26013, + 0xc5d1b023, 0x286085f0, 0xca417918, 0xb8db38ef, 0x8e79dcb0, 0x603a180e, + 0x6c9e0e8b, 0xb01e8a3e, 0xd71577c1, 0xbd314b27, 0x78af2fda, 0x55605c60, + 0xe65525f3, 0xaa55ab94, 0x57489862, 0x63e81440, 0x55ca396a, 0x2aab10b6, + 0xb4cc5c34, 0x1141e8ce, 0xa15486af, 0x7c72e993, 0xb3ee1411, 0x636fbc2a, + 0x2ba9c55d, 0x741831f6, 0xce5c3e16, 0x9b87931e, 0xafd6ba33, 0x6c24cf5c, + 0x7a325381, 0x28958677, 0x3b8f4898, 0x6b4bb9af, 0xc4bfe81b, 0x66282193, + 0x61d809cc, 0xfb21a991, 0x487cac60, 0x5dec8032, 0xef845d5d, 0xe98575b1, + 0xdc262302, 0xeb651b88, 0x23893e81, 0xd396acc5, 0x0f6d6ff3, 0x83f44239, + 0x2e0b4482, 0xa4842004, 0x69c8f04a, 0x9e1f9b5e, 0x21c66842, 0xf6e96c9a, + 0x670c9c61, 0xabd388f0, 0x6a51a0d2, 0xd8542f68, 0x960fa728, 0xab5133a3, + 0x6eef0b6c, 0x137a3be4, 0xba3bf050, 0x7efb2a98, 0xa1f1651d, 0x39af0176, + 0x66ca593e, 0x82430e88, 0x8cee8619, 0x456f9fb4, 0x7d84a5c3, 0x3b8b5ebe, + 0xe06f75d8, 0x85c12073, 0x401a449f, 0x56c16aa6, 0x4ed3aa62, 0x363f7706, + 0x1bfedf72, 0x429b023d, 0x37d0d724, 0xd00a1248, 0xdb0fead3, 0x49f1c09b, + 0x075372c9, 0x80991b7b, 0x25d479d8, 0xf6e8def7, 0xe3fe501a, 0xb6794c3b, + 0x976ce0bd, 0x04c006ba, 0xc1a94fb6, 0x409f60c4, 0x5e5c9ec2, 0x196a2463, + 0x68fb6faf, 0x3e6c53b5, 0x1339b2eb, 0x3b52ec6f, 0x6dfc511f, 0x9b30952c, + 0xcc814544, 0xaf5ebd09, 0xbee3d004, 0xde334afd, 0x660f2807, 0x192e4bb3, + 0xc0cba857, 0x45c8740f, 0xd20b5f39, 0xb9d3fbdb, 0x5579c0bd, 0x1a60320a, + 0xd6a100c6, 0x402c7279, 0x679f25fe, 0xfb1fa3cc, 0x8ea5e9f8, 0xdb3222f8, + 0x3c7516df, 0xfd616b15, 0x2f501ec8, 0xad0552ab, 0x323db5fa, 0xfd238760, + 0x53317b48, 0x3e00df82, 0x9e5c57bb, 0xca6f8ca0, 0x1a87562e, 0xdf1769db, + 0xd542a8f6, 0x287effc3, 0xac6732c6, 0x8c4f5573, 0x695b27b0, 0xbbca58c8, + 0xe1ffa35d, 0xb8f011a0, 0x10fa3d98, 0xfd2183b8, 0x4afcb56c, 0x2dd1d35b, + 0x9a53e479, 0xb6f84565, 0xd28e49bc, 0x4bfb9790, 0xe1ddf2da, 0xa4cb7e33, + 0x62fb1341, 0xcee4c6e8, 0xef20cada, 0x36774c01, 0xd07e9efe, 0x2bf11fb4, + 0x95dbda4d, 0xae909198, 0xeaad8e71, 0x6b93d5a0, 0xd08ed1d0, 0xafc725e0, + 0x8e3c5b2f, 0x8e7594b7, 0x8ff6e2fb, 0xf2122b64, 0x8888b812, 0x900df01c, + 0x4fad5ea0, 0x688fc31c, 0xd1cff191, 0xb3a8c1ad, 0x2f2f2218, 0xbe0e1777, + 0xea752dfe, 0x8b021fa1, 0xe5a0cc0f, 0xb56f74e8, 0x18acf3d6, 0xce89e299, + 0xb4a84fe0, 0xfd13e0b7, 0x7cc43b81, 0xd2ada8d9, 0x165fa266, 0x80957705, + 0x93cc7314, 0x211a1477, 0xe6ad2065, 0x77b5fa86, 0xc75442f5, 0xfb9d35cf, + 0xebcdaf0c, 0x7b3e89a0, 0xd6411bd3, 0xae1e7e49, 0x00250e2d, 0x2071b35e, + 0x226800bb, 0x57b8e0af, 0x2464369b, 0xf009b91e, 0x5563911d, 0x59dfa6aa, + 0x78c14389, 0xd95a537f, 0x207d5ba2, 0x02e5b9c5, 0x83260376, 0x6295cfa9, + 0x11c81968, 0x4e734a41, 0xb3472dca, 0x7b14a94a, 0x1b510052, 0x9a532915, + 0xd60f573f, 0xbc9bc6e4, 0x2b60a476, 0x81e67400, 0x08ba6fb5, 0x571be91f, + 0xf296ec6b, 0x2a0dd915, 0xb6636521, 0xe7b9f9b6, 0xff34052e, 0xc5855664, + 0x53b02d5d, 0xa99f8fa1, 0x08ba4799, 0x6e85076a, +} + +var s1 = [256]uint32{ + 0x4b7a70e9, 0xb5b32944, 0xdb75092e, 0xc4192623, 0xad6ea6b0, 0x49a7df7d, + 0x9cee60b8, 0x8fedb266, 0xecaa8c71, 0x699a17ff, 0x5664526c, 0xc2b19ee1, + 0x193602a5, 0x75094c29, 0xa0591340, 0xe4183a3e, 0x3f54989a, 0x5b429d65, + 0x6b8fe4d6, 0x99f73fd6, 0xa1d29c07, 0xefe830f5, 0x4d2d38e6, 0xf0255dc1, + 0x4cdd2086, 0x8470eb26, 0x6382e9c6, 0x021ecc5e, 0x09686b3f, 0x3ebaefc9, + 0x3c971814, 0x6b6a70a1, 0x687f3584, 0x52a0e286, 0xb79c5305, 0xaa500737, + 0x3e07841c, 0x7fdeae5c, 0x8e7d44ec, 0x5716f2b8, 0xb03ada37, 0xf0500c0d, + 0xf01c1f04, 0x0200b3ff, 0xae0cf51a, 0x3cb574b2, 0x25837a58, 0xdc0921bd, + 0xd19113f9, 0x7ca92ff6, 0x94324773, 0x22f54701, 0x3ae5e581, 0x37c2dadc, + 0xc8b57634, 0x9af3dda7, 0xa9446146, 0x0fd0030e, 0xecc8c73e, 0xa4751e41, + 0xe238cd99, 0x3bea0e2f, 0x3280bba1, 0x183eb331, 0x4e548b38, 0x4f6db908, + 0x6f420d03, 0xf60a04bf, 0x2cb81290, 0x24977c79, 0x5679b072, 0xbcaf89af, + 0xde9a771f, 0xd9930810, 0xb38bae12, 0xdccf3f2e, 0x5512721f, 0x2e6b7124, + 0x501adde6, 0x9f84cd87, 0x7a584718, 0x7408da17, 0xbc9f9abc, 0xe94b7d8c, + 0xec7aec3a, 0xdb851dfa, 0x63094366, 0xc464c3d2, 0xef1c1847, 0x3215d908, + 0xdd433b37, 0x24c2ba16, 0x12a14d43, 0x2a65c451, 0x50940002, 0x133ae4dd, + 0x71dff89e, 0x10314e55, 0x81ac77d6, 0x5f11199b, 0x043556f1, 0xd7a3c76b, + 0x3c11183b, 0x5924a509, 0xf28fe6ed, 0x97f1fbfa, 0x9ebabf2c, 0x1e153c6e, + 0x86e34570, 0xeae96fb1, 0x860e5e0a, 0x5a3e2ab3, 0x771fe71c, 0x4e3d06fa, + 0x2965dcb9, 0x99e71d0f, 0x803e89d6, 0x5266c825, 0x2e4cc978, 0x9c10b36a, + 0xc6150eba, 0x94e2ea78, 0xa5fc3c53, 0x1e0a2df4, 0xf2f74ea7, 0x361d2b3d, + 0x1939260f, 0x19c27960, 0x5223a708, 0xf71312b6, 0xebadfe6e, 0xeac31f66, + 0xe3bc4595, 0xa67bc883, 0xb17f37d1, 0x018cff28, 0xc332ddef, 0xbe6c5aa5, + 0x65582185, 0x68ab9802, 0xeecea50f, 0xdb2f953b, 0x2aef7dad, 0x5b6e2f84, + 0x1521b628, 0x29076170, 0xecdd4775, 0x619f1510, 0x13cca830, 0xeb61bd96, + 0x0334fe1e, 0xaa0363cf, 0xb5735c90, 0x4c70a239, 0xd59e9e0b, 0xcbaade14, + 0xeecc86bc, 0x60622ca7, 0x9cab5cab, 0xb2f3846e, 0x648b1eaf, 0x19bdf0ca, + 0xa02369b9, 0x655abb50, 0x40685a32, 0x3c2ab4b3, 0x319ee9d5, 0xc021b8f7, + 0x9b540b19, 0x875fa099, 0x95f7997e, 0x623d7da8, 0xf837889a, 0x97e32d77, + 0x11ed935f, 0x16681281, 0x0e358829, 0xc7e61fd6, 0x96dedfa1, 0x7858ba99, + 0x57f584a5, 0x1b227263, 0x9b83c3ff, 0x1ac24696, 0xcdb30aeb, 0x532e3054, + 0x8fd948e4, 0x6dbc3128, 0x58ebf2ef, 0x34c6ffea, 0xfe28ed61, 0xee7c3c73, + 0x5d4a14d9, 0xe864b7e3, 0x42105d14, 0x203e13e0, 0x45eee2b6, 0xa3aaabea, + 0xdb6c4f15, 0xfacb4fd0, 0xc742f442, 0xef6abbb5, 0x654f3b1d, 0x41cd2105, + 0xd81e799e, 0x86854dc7, 0xe44b476a, 0x3d816250, 0xcf62a1f2, 0x5b8d2646, + 0xfc8883a0, 0xc1c7b6a3, 0x7f1524c3, 0x69cb7492, 0x47848a0b, 0x5692b285, + 0x095bbf00, 0xad19489d, 0x1462b174, 0x23820e00, 0x58428d2a, 0x0c55f5ea, + 0x1dadf43e, 0x233f7061, 0x3372f092, 0x8d937e41, 0xd65fecf1, 0x6c223bdb, + 0x7cde3759, 0xcbee7460, 0x4085f2a7, 0xce77326e, 0xa6078084, 0x19f8509e, + 0xe8efd855, 0x61d99735, 0xa969a7aa, 0xc50c06c2, 0x5a04abfc, 0x800bcadc, + 0x9e447a2e, 0xc3453484, 0xfdd56705, 0x0e1e9ec9, 0xdb73dbd3, 0x105588cd, + 0x675fda79, 0xe3674340, 0xc5c43465, 0x713e38d8, 0x3d28f89e, 0xf16dff20, + 0x153e21e7, 0x8fb03d4a, 0xe6e39f2b, 0xdb83adf7, +} + +var s2 = [256]uint32{ + 0xe93d5a68, 0x948140f7, 0xf64c261c, 0x94692934, 0x411520f7, 0x7602d4f7, + 0xbcf46b2e, 0xd4a20068, 0xd4082471, 0x3320f46a, 0x43b7d4b7, 0x500061af, + 0x1e39f62e, 0x97244546, 0x14214f74, 0xbf8b8840, 0x4d95fc1d, 0x96b591af, + 0x70f4ddd3, 0x66a02f45, 0xbfbc09ec, 0x03bd9785, 0x7fac6dd0, 0x31cb8504, + 0x96eb27b3, 0x55fd3941, 0xda2547e6, 0xabca0a9a, 0x28507825, 0x530429f4, + 0x0a2c86da, 0xe9b66dfb, 0x68dc1462, 0xd7486900, 0x680ec0a4, 0x27a18dee, + 0x4f3ffea2, 0xe887ad8c, 0xb58ce006, 0x7af4d6b6, 0xaace1e7c, 0xd3375fec, + 0xce78a399, 0x406b2a42, 0x20fe9e35, 0xd9f385b9, 0xee39d7ab, 0x3b124e8b, + 0x1dc9faf7, 0x4b6d1856, 0x26a36631, 0xeae397b2, 0x3a6efa74, 0xdd5b4332, + 0x6841e7f7, 0xca7820fb, 0xfb0af54e, 0xd8feb397, 0x454056ac, 0xba489527, + 0x55533a3a, 0x20838d87, 0xfe6ba9b7, 0xd096954b, 0x55a867bc, 0xa1159a58, + 0xcca92963, 0x99e1db33, 0xa62a4a56, 0x3f3125f9, 0x5ef47e1c, 0x9029317c, + 0xfdf8e802, 0x04272f70, 0x80bb155c, 0x05282ce3, 0x95c11548, 0xe4c66d22, + 0x48c1133f, 0xc70f86dc, 0x07f9c9ee, 0x41041f0f, 0x404779a4, 0x5d886e17, + 0x325f51eb, 0xd59bc0d1, 0xf2bcc18f, 0x41113564, 0x257b7834, 0x602a9c60, + 0xdff8e8a3, 0x1f636c1b, 0x0e12b4c2, 0x02e1329e, 0xaf664fd1, 0xcad18115, + 0x6b2395e0, 0x333e92e1, 0x3b240b62, 0xeebeb922, 0x85b2a20e, 0xe6ba0d99, + 0xde720c8c, 0x2da2f728, 0xd0127845, 0x95b794fd, 0x647d0862, 0xe7ccf5f0, + 0x5449a36f, 0x877d48fa, 0xc39dfd27, 0xf33e8d1e, 0x0a476341, 0x992eff74, + 0x3a6f6eab, 0xf4f8fd37, 0xa812dc60, 0xa1ebddf8, 0x991be14c, 0xdb6e6b0d, + 0xc67b5510, 0x6d672c37, 0x2765d43b, 0xdcd0e804, 0xf1290dc7, 0xcc00ffa3, + 0xb5390f92, 0x690fed0b, 0x667b9ffb, 0xcedb7d9c, 0xa091cf0b, 0xd9155ea3, + 0xbb132f88, 0x515bad24, 0x7b9479bf, 0x763bd6eb, 0x37392eb3, 0xcc115979, + 0x8026e297, 0xf42e312d, 0x6842ada7, 0xc66a2b3b, 0x12754ccc, 0x782ef11c, + 0x6a124237, 0xb79251e7, 0x06a1bbe6, 0x4bfb6350, 0x1a6b1018, 0x11caedfa, + 0x3d25bdd8, 0xe2e1c3c9, 0x44421659, 0x0a121386, 0xd90cec6e, 0xd5abea2a, + 0x64af674e, 0xda86a85f, 0xbebfe988, 0x64e4c3fe, 0x9dbc8057, 0xf0f7c086, + 0x60787bf8, 0x6003604d, 0xd1fd8346, 0xf6381fb0, 0x7745ae04, 0xd736fccc, + 0x83426b33, 0xf01eab71, 0xb0804187, 0x3c005e5f, 0x77a057be, 0xbde8ae24, + 0x55464299, 0xbf582e61, 0x4e58f48f, 0xf2ddfda2, 0xf474ef38, 0x8789bdc2, + 0x5366f9c3, 0xc8b38e74, 0xb475f255, 0x46fcd9b9, 0x7aeb2661, 0x8b1ddf84, + 0x846a0e79, 0x915f95e2, 0x466e598e, 0x20b45770, 0x8cd55591, 0xc902de4c, + 0xb90bace1, 0xbb8205d0, 0x11a86248, 0x7574a99e, 0xb77f19b6, 0xe0a9dc09, + 0x662d09a1, 0xc4324633, 0xe85a1f02, 0x09f0be8c, 0x4a99a025, 0x1d6efe10, + 0x1ab93d1d, 0x0ba5a4df, 0xa186f20f, 0x2868f169, 0xdcb7da83, 0x573906fe, + 0xa1e2ce9b, 0x4fcd7f52, 0x50115e01, 0xa70683fa, 0xa002b5c4, 0x0de6d027, + 0x9af88c27, 0x773f8641, 0xc3604c06, 0x61a806b5, 0xf0177a28, 0xc0f586e0, + 0x006058aa, 0x30dc7d62, 0x11e69ed7, 0x2338ea63, 0x53c2dd94, 0xc2c21634, + 0xbbcbee56, 0x90bcb6de, 0xebfc7da1, 0xce591d76, 0x6f05e409, 0x4b7c0188, + 0x39720a3d, 0x7c927c24, 0x86e3725f, 0x724d9db9, 0x1ac15bb4, 0xd39eb8fc, + 0xed545578, 0x08fca5b5, 0xd83d7cd3, 0x4dad0fc4, 0x1e50ef5e, 0xb161e6f8, + 0xa28514d9, 0x6c51133c, 0x6fd5c7e7, 0x56e14ec4, 0x362abfce, 0xddc6c837, + 0xd79a3234, 0x92638212, 0x670efa8e, 0x406000e0, +} + +var s3 = [256]uint32{ + 0x3a39ce37, 0xd3faf5cf, 0xabc27737, 0x5ac52d1b, 0x5cb0679e, 0x4fa33742, + 0xd3822740, 0x99bc9bbe, 0xd5118e9d, 0xbf0f7315, 0xd62d1c7e, 0xc700c47b, + 0xb78c1b6b, 0x21a19045, 0xb26eb1be, 0x6a366eb4, 0x5748ab2f, 0xbc946e79, + 0xc6a376d2, 0x6549c2c8, 0x530ff8ee, 0x468dde7d, 0xd5730a1d, 0x4cd04dc6, + 0x2939bbdb, 0xa9ba4650, 0xac9526e8, 0xbe5ee304, 0xa1fad5f0, 0x6a2d519a, + 0x63ef8ce2, 0x9a86ee22, 0xc089c2b8, 0x43242ef6, 0xa51e03aa, 0x9cf2d0a4, + 0x83c061ba, 0x9be96a4d, 0x8fe51550, 0xba645bd6, 0x2826a2f9, 0xa73a3ae1, + 0x4ba99586, 0xef5562e9, 0xc72fefd3, 0xf752f7da, 0x3f046f69, 0x77fa0a59, + 0x80e4a915, 0x87b08601, 0x9b09e6ad, 0x3b3ee593, 0xe990fd5a, 0x9e34d797, + 0x2cf0b7d9, 0x022b8b51, 0x96d5ac3a, 0x017da67d, 0xd1cf3ed6, 0x7c7d2d28, + 0x1f9f25cf, 0xadf2b89b, 0x5ad6b472, 0x5a88f54c, 0xe029ac71, 0xe019a5e6, + 0x47b0acfd, 0xed93fa9b, 0xe8d3c48d, 0x283b57cc, 0xf8d56629, 0x79132e28, + 0x785f0191, 0xed756055, 0xf7960e44, 0xe3d35e8c, 0x15056dd4, 0x88f46dba, + 0x03a16125, 0x0564f0bd, 0xc3eb9e15, 0x3c9057a2, 0x97271aec, 0xa93a072a, + 0x1b3f6d9b, 0x1e6321f5, 0xf59c66fb, 0x26dcf319, 0x7533d928, 0xb155fdf5, + 0x03563482, 0x8aba3cbb, 0x28517711, 0xc20ad9f8, 0xabcc5167, 0xccad925f, + 0x4de81751, 0x3830dc8e, 0x379d5862, 0x9320f991, 0xea7a90c2, 0xfb3e7bce, + 0x5121ce64, 0x774fbe32, 0xa8b6e37e, 0xc3293d46, 0x48de5369, 0x6413e680, + 0xa2ae0810, 0xdd6db224, 0x69852dfd, 0x09072166, 0xb39a460a, 0x6445c0dd, + 0x586cdecf, 0x1c20c8ae, 0x5bbef7dd, 0x1b588d40, 0xccd2017f, 0x6bb4e3bb, + 0xdda26a7e, 0x3a59ff45, 0x3e350a44, 0xbcb4cdd5, 0x72eacea8, 0xfa6484bb, + 0x8d6612ae, 0xbf3c6f47, 0xd29be463, 0x542f5d9e, 0xaec2771b, 0xf64e6370, + 0x740e0d8d, 0xe75b1357, 0xf8721671, 0xaf537d5d, 0x4040cb08, 0x4eb4e2cc, + 0x34d2466a, 0x0115af84, 0xe1b00428, 0x95983a1d, 0x06b89fb4, 0xce6ea048, + 0x6f3f3b82, 0x3520ab82, 0x011a1d4b, 0x277227f8, 0x611560b1, 0xe7933fdc, + 0xbb3a792b, 0x344525bd, 0xa08839e1, 0x51ce794b, 0x2f32c9b7, 0xa01fbac9, + 0xe01cc87e, 0xbcc7d1f6, 0xcf0111c3, 0xa1e8aac7, 0x1a908749, 0xd44fbd9a, + 0xd0dadecb, 0xd50ada38, 0x0339c32a, 0xc6913667, 0x8df9317c, 0xe0b12b4f, + 0xf79e59b7, 0x43f5bb3a, 0xf2d519ff, 0x27d9459c, 0xbf97222c, 0x15e6fc2a, + 0x0f91fc71, 0x9b941525, 0xfae59361, 0xceb69ceb, 0xc2a86459, 0x12baa8d1, + 0xb6c1075e, 0xe3056a0c, 0x10d25065, 0xcb03a442, 0xe0ec6e0e, 0x1698db3b, + 0x4c98a0be, 0x3278e964, 0x9f1f9532, 0xe0d392df, 0xd3a0342b, 0x8971f21e, + 0x1b0a7441, 0x4ba3348c, 0xc5be7120, 0xc37632d8, 0xdf359f8d, 0x9b992f2e, + 0xe60b6f47, 0x0fe3f11d, 0xe54cda54, 0x1edad891, 0xce6279cf, 0xcd3e7e6f, + 0x1618b166, 0xfd2c1d05, 0x848fd2c5, 0xf6fb2299, 0xf523f357, 0xa6327623, + 0x93a83531, 0x56cccd02, 0xacf08162, 0x5a75ebb5, 0x6e163697, 0x88d273cc, + 0xde966292, 0x81b949d0, 0x4c50901b, 0x71c65614, 0xe6c6c7bd, 0x327a140a, + 0x45e1d006, 0xc3f27b9a, 0xc9aa53fd, 0x62a80f00, 0xbb25bfe2, 0x35bdd2f6, + 0x71126905, 0xb2040222, 0xb6cbcf7c, 0xcd769c2b, 0x53113ec0, 0x1640e3d3, + 0x38abbd60, 0x2547adf0, 0xba38209c, 0xf746ce76, 0x77afa1c5, 0x20756060, + 0x85cbfe4e, 0x8ae88dd8, 0x7aaaf9b0, 0x4cf9aa7e, 0x1948c25c, 0x02fb8a8c, + 0x01c36ae4, 0xd6ebe1f9, 0x90d4f869, 0xa65cdea0, 0x3f09252d, 0xc208e69f, + 0xb74e6132, 0xce77e25b, 0x578fdfe3, 0x3ac372e6, +} + +var p = [18]uint32{ + 0x243f6a88, 0x85a308d3, 0x13198a2e, 0x03707344, 0xa4093822, 0x299f31d0, + 0x082efa98, 0xec4e6c89, 0x452821e6, 0x38d01377, 0xbe5466cf, 0x34e90c6c, + 0xc0ac29b7, 0xc97c50dd, 0x3f84d5b5, 0xb5470917, 0x9216d5d9, 0x8979fb1b, +} diff --git a/backend/vendor/modules.txt b/backend/vendor/modules.txt index 850a4c21..5bc8fb60 100644 --- a/backend/vendor/modules.txt +++ b/backend/vendor/modules.txt @@ -540,7 +540,9 @@ golang.org/x/arch/x86/x86asm # golang.org/x/crypto v0.51.0 ## explicit; go 1.25.0 golang.org/x/crypto/argon2 +golang.org/x/crypto/bcrypt golang.org/x/crypto/blake2b +golang.org/x/crypto/blowfish golang.org/x/crypto/chacha20 golang.org/x/crypto/chacha20poly1305 golang.org/x/crypto/cryptobyte diff --git a/frontend/src/lib/api/api.js b/frontend/src/lib/api/api.js index 5e7fd631..f8981abc 100644 --- a/frontend/src/lib/api/api.js +++ b/frontend/src/lib/api/api.js @@ -1261,6 +1261,31 @@ export class API { 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}`)); + }, + // prune the company's disabled (soft-deleted) recipients past the retention window + prune: async (companyID) => { + return await postJSON(this.getPath(`/company/scim/${companyID}/prune`), {}); + } + }, /** * Get the auto-prune orphaned recipients setting for a company. * Returns only the per-company enabled flag. @@ -2332,6 +2357,46 @@ export class API { return await postJSON(this.getPath(`/option/auto-prune`), option); }, + /** + * Get the global SCIM domain. Empty string means SCIM serving is disabled. + * + * @returns {Promise} + */ + getScimDomain: async () => { + return await getJSON(this.getPath(`/option/scim-domain`)); + }, + + /** + * Set the global SCIM domain. Pass an empty string to disable SCIM serving. + * The value must be an existing global domain. + * + * @param {string} domain + * @returns {Promise} + */ + setScimDomain: async (domain) => { + return await postJSON(this.getPath(`/option/scim-domain`), { domain }); + }, + + /** + * Get the SCIM soft-delete retention window in days. + * + * @returns {Promise} + */ + getScimRetentionDays: async () => { + return await getJSON(this.getPath(`/option/scim-retention-days`)); + }, + + /** + * Set the SCIM soft-delete retention window in days. Disabled recipients are + * pruned (anonymized + deleted) after this many days. + * + * @param {number} days + * @returns {Promise} + */ + setScimRetentionDays: async (days) => { + return await postJSON(this.getPath(`/option/scim-retention-days`), { days }); + }, + /** * Set setting by key and value. * diff --git a/frontend/src/lib/api/apiProxy.js b/frontend/src/lib/api/apiProxy.js index 6c0b2d99..4b49e017 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 00000000..1fdb34b4 --- /dev/null +++ b/frontend/src/lib/components/modal/ScimModal.svelte @@ -0,0 +1,429 @@ + + + +
+ {#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 +

+ {#if scimDomain} +
+ + +
+ {:else} +

+ No SCIM domain is configured. Set a global SCIM domain under Settings → System before + connecting an identity provider. +

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

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. +

+
+ + + +

+ Permanently remove all disabled recipients for + {company?.name} now? +

+

+ this removes them immediately, before their retention window ends. their identity is deleted and + cannot be recovered; historical campaign results are kept in anonymized form. +

+
+ + + +

+ 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/lib/components/table/TableRow.svelte b/frontend/src/lib/components/table/TableRow.svelte index 75d362f6..3b2ec63c 100644 --- a/frontend/src/lib/components/table/TableRow.svelte +++ b/frontend/src/lib/components/table/TableRow.svelte @@ -1,5 +1,15 @@ + + diff --git a/frontend/src/routes/campaign/+page.svelte b/frontend/src/routes/campaign/+page.svelte index 940dd820..af554c3d 100644 --- a/frontend/src/routes/campaign/+page.svelte +++ b/frontend/src/routes/campaign/+page.svelte @@ -2784,12 +2784,24 @@
{#each recipients as recipient}
{recipient.email} + {#if recipient.scimSoftDeletedAt} + + Disabled + + {/if} {#if recipient.firstName || recipient.lastName} {#each campaignRecipients as recp (recp.id)} - + {#if liveSessions.size > 0} {#if liveSessions.has(recp.id)} @@ -2124,7 +2129,16 @@ on:click={() => openEventsModal(recp.recipientID)} class="block w-full py-1 text-left" > - {recp.recipient.email} + + {recp.recipient.email} + {#if recp.recipient.scimSoftDeletedAt} + + Disabled + + {/if} + {/if} @@ -2151,15 +2165,20 @@ ? campaign.companyID ? 'Switch to company view to perform this action' : 'Switch to global view to perform this action' - : recp.closedAt - ? 'Campaign is closed' - : recp.cancelledAt - ? 'Recipient cancelled' - : recp.sentAt - ? `Send message again (last sent: ${new Date(recp.sentAt).toLocaleDateString()})` - : `Send message to recipient`} + : recp.recipient?.scimSoftDeletedAt + ? 'Recipient is disabled in the identity provider and cannot be sent to' + : recp.closedAt + ? 'Campaign is closed' + : recp.cancelledAt + ? 'Recipient cancelled' + : recp.sentAt + ? `Send message again (last sent: ${new Date(recp.sentAt).toLocaleDateString()})` + : `Send message to recipient`} on:click={() => showSendMessageModal(recp.id, recp.recipient)} - disabled={!!campaign.closedAt || recp.cancelledAt || isContextMismatch()} + disabled={!!campaign.closedAt || + recp.cancelledAt || + !!recp.recipient?.scimSoftDeletedAt || + isContextMismatch()} /> { + scimCompany = company; + isScimModalVisible = true; + }; + + const closeScimModal = () => { + isScimModalVisible = false; + scimCompany = null; + }; + const onConfirmExportCompany = async () => { try { showIsLoading(); @@ -448,6 +462,7 @@ on:click={() => openViewCommentModal(company)} /> openExportCompanyModal(company)} /> + openScimModal(company)} /> goto(`/company/${company.id}/stats`)} @@ -630,6 +645,8 @@
+ + {#each recipients as recipient} - + {#if recipient.email} {recipient.email} {/if} + {#if recipient.scimSoftDeletedAt} + + Disabled + + {/if} {#if recipient.firstName} diff --git a/frontend/src/routes/recipient/group/+page.svelte b/frontend/src/routes/recipient/group/+page.svelte index 58977755..fa3be0d1 100644 --- a/frontend/src/routes/recipient/group/+page.svelte +++ b/frontend/src/routes/recipient/group/+page.svelte @@ -362,6 +362,13 @@ {/if} {group.name} + {#if group.scimEnabled} + + SCIM + + {/if} diff --git a/frontend/src/routes/recipient/group/[id]/+page.svelte b/frontend/src/routes/recipient/group/[id]/+page.svelte index b323ffb3..3fe45b72 100644 --- a/frontend/src/routes/recipient/group/[id]/+page.svelte +++ b/frontend/src/routes/recipient/group/[id]/+page.svelte @@ -462,8 +462,24 @@ isGhost={isTableLoading} > {#each recipients as recipient} - - + + + + {recipient.email ?? ''} + {#if recipient.scimSoftDeletedAt} + + Disabled + + {/if} + + {recipient.firstName} diff --git a/frontend/src/routes/settings/panels/Access.svelte b/frontend/src/routes/settings/panels/Access.svelte index 455648a3..755b8ec2 100644 --- a/frontend/src/routes/settings/panels/Access.svelte +++ b/frontend/src/routes/settings/panels/Access.svelte @@ -14,6 +14,8 @@ import FormFooter from '$lib/components/FormFooter.svelte'; import TextField from '$lib/components/TextField.svelte'; import PasswordField from '$lib/components/PasswordField.svelte'; + import TextFieldSelect from '$lib/components/TextFieldSelect.svelte'; + import Form from '$lib/components/Form.svelte'; let loaded = false; let ssoForm = null; @@ -29,17 +31,94 @@ clientSecret: null }; + // SCIM provisioning: single global domain that serves the SCIM endpoints + let scimDomain = ''; + let scimDomainOptions = [{ value: '', label: '— Disabled —' }]; + // retention window (days) before a SCIM-disabled recipient is pruned + let scimRetentionDays = 30; + onMount(async () => { try { await refreshSSO(); if (!ssoSettingsFormValues.redirectURL) { ssoSettingsFormValues.redirectURL = `${location.origin}/api/v1/sso/entra-id/auth`; } + await refreshScimDomain(); + await refreshScimRetention(); } finally { loaded = true; } }); + async function refreshScimRetention() { + try { + const res = await api.option.getScimRetentionDays(); + if (res.success) { + scimRetentionDays = res.data.days; + } + } catch (e) { + console.error('failed to load SCIM retention setting', e); + } + } + + async function setScimRetention() { + try { + const days = parseInt(scimRetentionDays, 10); + if (isNaN(days) || days < 0) { + addToast('Retention days must be zero or positive', 'Error'); + await refreshScimRetention(); + return; + } + const res = await api.option.setScimRetentionDays(days); + if (res.success) { + addToast('SCIM retention updated', 'Success'); + } else { + addToast(res.error || 'Failed to update SCIM retention', 'Error'); + await refreshScimRetention(); + } + } catch (e) { + addToast('Failed to update SCIM retention', 'Error'); + console.error(e); + } + } + + async function refreshScimDomain() { + try { + // only normal global domains may serve SCIM — exclude AiTM proxy domains + const [current, domains] = await Promise.all([ + api.option.getScimDomain(), + api.domain.getAllSubsetWithoutProxies({ perPage: 1000 }, null) + ]); + if (current.success) { + scimDomain = current.data.domain || ''; + } + if (domains.success) { + const names = (domains.data.rows || []).map((d) => d.name); + scimDomainOptions = [ + { value: '', label: '— Disabled —' }, + ...names.map((n) => ({ value: n, label: n })) + ]; + } + } catch (e) { + console.error('failed to load SCIM domain settings', e); + } + } + + async function setScimDomain() { + try { + const res = await api.option.setScimDomain(scimDomain); + if (res.success) { + addToast(scimDomain ? 'SCIM domain updated' : 'SCIM serving disabled', 'Success'); + } else { + addToast(res.error || 'Failed to update SCIM domain', 'Error'); + await refreshScimDomain(); + } + } catch (e) { + addToast('Failed to update SCIM domain', 'Error'); + console.error(e); + } + } + async function refreshSSO() { try { const res = await api.option.get('sso_login'); @@ -142,6 +221,36 @@ {/if} + + +

+ Global domain that serves SCIM provisioning. Must be publicly reachable on 443 with a valid + certificate; prefer a dedicated domain not used for campaigns. +

+
+ SCIM domain +
+

+ When a user is deprovisioned in your identity provider they are marked disabled and excluded + from campaigns. They are kept for this many days before being permanently removed (their + campaign history is anonymized). Set to 0 to remove on the next prune. +

+
+ + days retention +
+
{/if}