SCIM provisioning for companies with disabled recipient handling

Signed-off-by: Ronni Skansing <rskansing@gmail.com>
This commit is contained in:
Ronni Skansing committed 2026-06-11 20:34:13 +02:00
1 parent 48805acb5f
commit 61ecc4c505
46 files changed
+6113 -76

No files matched your search

+54 -16
View File
@@ -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).
+13
View File
@@ -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,
}
+2
View File
@@ -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},
}
+67
View File
@@ -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()
+21
View File
@@ -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,
+154
View File
@@ -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{})
}
+68
View File
@@ -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)
+677
View File
@@ -0,0 +1,677 @@
package controller
import (
"context"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/go-errors/errors"
"github.com/google/uuid"
"github.com/phishingclub/phishingclub/errs"
"github.com/phishingclub/phishingclub/service"
"gorm.io/gorm"
)
const (
scimContentType = "application/scim+json"
)
// Scim is the SCIM v2 protocol controller.
// all handlers authenticate via bearer token before dispatching to the service layer.
type Scim struct {
Common
ScimService *service.Scim
}
// scimError writes a SCIM-compliant error response
func scimError(g *gin.Context, status int, detail string, scimType ...string) {
body := service.ScimError{
Schemas: []string{"urn:ietf:params:scim:api:messages:2.0:Error"},
Status: status,
Detail: detail,
}
if len(scimType) > 0 {
body.ScimType = scimType[0]
}
g.Header("Content-Type", scimContentType)
g.JSON(status, body)
g.Abort()
}
// scimOK writes a SCIM-compliant success response with Content-Type application/scim+json
func scimOK(g *gin.Context, status int, body any) {
g.Header("Content-Type", scimContentType)
g.JSON(status, body)
}
// authenticate validates the Authorization: Bearer <token> header and returns
// the verified SCIM config for the company. on failure it writes the error
// response itself and returns nil, false.
func (c *Scim) authenticate(g *gin.Context, companyID *uuid.UUID) (*service.ScimConfigResult, bool) {
authHeader := g.GetHeader("Authorization")
const bearerPrefix = "Bearer "
if !strings.HasPrefix(authHeader, bearerPrefix) {
scimError(g, http.StatusUnauthorized, "missing or malformed Authorization header")
return nil, false
}
token := strings.TrimPrefix(authHeader, bearerPrefix)
if token == "" {
scimError(g, http.StatusUnauthorized, "empty bearer token")
return nil, false
}
config, authed, err := c.ScimService.VerifyAndLoadConfig(g.Request.Context(), companyID, token)
if err != nil {
if isNotFound(err) {
scimError(g, http.StatusUnauthorized, "no SCIM configuration found for this company")
return nil, false
}
c.Logger.Errorw("scim auth: error verifying token", "error", err)
scimError(g, http.StatusInternalServerError, "internal error during authentication")
return nil, false
}
if !authed {
scimError(g, http.StatusUnauthorized, "invalid bearer token or SCIM provisioning is disabled")
return nil, false
}
return &service.ScimConfigResult{Config: config, CompanyID: companyID}, true
}
// parseCompanyID extracts and parses the :companyID path parameter.
// writes the error response and returns false on failure.
func (c *Scim) parseCompanyID(g *gin.Context) (*uuid.UUID, bool) {
id, err := uuid.Parse(g.Param("companyID"))
if err != nil {
scimError(g, http.StatusBadRequest, "invalid companyID in URL path")
return nil, false
}
return &id, true
}
// parseUserID extracts and parses the :userID path parameter.
// an unparseable id means the resource cannot exist, so 404 is correct per rfc 7644.
func (c *Scim) parseUserID(g *gin.Context) (*uuid.UUID, bool) {
id, err := uuid.Parse(g.Param("userID"))
if err != nil {
scimError(g, http.StatusNotFound, "user not found")
return nil, false
}
return &id, true
}
// parseGroupID extracts and parses the :groupID path parameter.
// an unparseable id means the resource cannot exist, so 404 is correct per rfc 7644.
func (c *Scim) parseGroupID(g *gin.Context) (*uuid.UUID, bool) {
id, err := uuid.Parse(g.Param("groupID"))
if err != nil {
scimError(g, http.StatusNotFound, "group not found")
return nil, false
}
return &id, true
}
// scimBaseURL builds the base URL for SCIM resource locations from the request
func scimBaseURL(g *gin.Context, companyID *uuid.UUID) string {
scheme := "https"
if g.Request.TLS == nil {
scheme = "http"
}
return scheme + "://" + g.Request.Host + "/api/v1/scim/v2/" + companyID.String()
}
// isNotFound returns true when the error wraps gorm.ErrRecordNotFound
func isNotFound(err error) bool {
if err == nil {
return false
}
return strings.Contains(err.Error(), gorm.ErrRecordNotFound.Error())
}
// ── ServiceProviderConfig ─────────────────────────────────────────────────────
// ServiceProviderConfig handles GET /scim/v2/:companyID/ServiceProviderConfig
func (c *Scim) ServiceProviderConfig(g *gin.Context) {
companyID, ok := c.parseCompanyID(g)
if !ok {
return
}
if _, ok := c.authenticate(g, companyID); !ok {
return
}
base := scimBaseURL(g, companyID)
scimOK(g, http.StatusOK, c.ScimService.ServiceProviderConfig(base))
}
// ── Schemas ───────────────────────────────────────────────────────────────────
// Schemas handles GET /api/v1/scim/v2/:companyID/Schemas
func (c *Scim) Schemas(g *gin.Context) {
companyID, ok := c.parseCompanyID(g)
if !ok {
return
}
if _, ok := c.authenticate(g, companyID); !ok {
return
}
base := scimBaseURL(g, companyID)
schemas := c.ScimService.Schemas(base)
scimOK(g, http.StatusOK, gin.H{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:ListResponse"},
"totalResults": len(schemas),
"startIndex": 1,
"itemsPerPage": len(schemas),
"Resources": schemas,
})
}
// GetSchema handles GET /api/v1/scim/v2/:companyID/Schemas/:schemaID
func (c *Scim) GetSchema(g *gin.Context) {
companyID, ok := c.parseCompanyID(g)
if !ok {
return
}
if _, ok := c.authenticate(g, companyID); !ok {
return
}
schemaID := g.Param("schemaID")
base := scimBaseURL(g, companyID)
schema, found := c.ScimService.GetSchemaByID(base, schemaID)
if !found {
scimError(g, http.StatusNotFound, "schema not found")
return
}
scimOK(g, http.StatusOK, schema)
}
// ── ResourceTypes ─────────────────────────────────────────────────────────────
// ResourceTypes handles GET /scim/v2/:companyID/ResourceTypes
func (c *Scim) ResourceTypes(g *gin.Context) {
companyID, ok := c.parseCompanyID(g)
if !ok {
return
}
if _, ok := c.authenticate(g, companyID); !ok {
return
}
base := scimBaseURL(g, companyID)
types := c.ScimService.ResourceTypes(base)
scimOK(g, http.StatusOK, gin.H{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:ListResponse"},
"totalResults": len(types),
"startIndex": 1,
"itemsPerPage": len(types),
"Resources": types,
})
}
// GetResourceType handles GET /scim/v2/:companyID/ResourceTypes/:resourceTypeID
func (c *Scim) GetResourceType(g *gin.Context) {
companyID, ok := c.parseCompanyID(g)
if !ok {
return
}
if _, ok := c.authenticate(g, companyID); !ok {
return
}
resourceTypeID := g.Param("resourceTypeID")
base := scimBaseURL(g, companyID)
rt, found := c.ScimService.GetResourceTypeByID(base, resourceTypeID)
if !found {
scimError(g, http.StatusNotFound, "resource type not found")
return
}
scimOK(g, http.StatusOK, rt)
}
// ── Groups ────────────────────────────────────────────────────────────────────
// ListGroups handles GET /api/v1/scim/v2/:companyID/Groups
func (c *Scim) ListGroups(g *gin.Context) {
companyID, ok := c.parseCompanyID(g)
if !ok {
return
}
result, ok := c.authenticate(g, companyID)
if !ok {
return
}
base := scimBaseURL(g, companyID)
startIndex := parseIntQuery(g, "startIndex", 1)
count := parseIntQuery(g, "count", -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
}
+10
View File
@@ -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
+34
View File
@@ -0,0 +1,34 @@
package database
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
const (
COMPANY_SCIM_CONFIG_TABLE = "company_scim_configs"
)
// CompanyScimConfig holds the SCIM configuration for a company
type CompanyScimConfig struct {
ID *uuid.UUID `gorm:"primary_key;not null;unique;type:uuid"`
CreatedAt *time.Time `gorm:"not null;index;"`
UpdatedAt *time.Time `gorm:"not null;index;"`
CompanyID *uuid.UUID `gorm:"not null;unique;type:uuid;index;"` // one-to-one with company
TokenHash string `gorm:"not null;"` // bcrypt hash of the bearer token
TokenPrefix string `gorm:"not null;"` // first 8 chars of token for identification
Enabled bool `gorm:"not null;default:true"`
LastSyncAt *time.Time // nullable: last time SCIM pushed an update
Company *Company `gorm:"foreignKey:CompanyID"`
}
func (e *CompanyScimConfig) Migrate(db *gorm.DB) error {
return nil
}
func (CompanyScimConfig) TableName() string {
return COMPANY_SCIM_CONFIG_TABLE
}
+13 -7
View File
@@ -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;"`
+34
View File
@@ -94,6 +94,40 @@ func (e ValidationError) Error() string {
return e.Err.Error()
}
// SyntaxError is returned when a request is structurally invalid (scim invalidSyntax)
type SyntaxError struct {
Err error
}
// NewSyntaxError creates a new syntax error
func NewSyntaxError(err error) error {
return SyntaxError{
Err: err,
}
}
// Error returns the syntax error
func (e SyntaxError) Error() string {
return e.Err.Error()
}
// ConflictError is returned when a resource already exists (scim uniqueness conflict)
type ConflictError struct {
Err error
}
// NewConflictError creates a new conflict error
func NewConflictError(err error) error {
return ConflictError{
Err: err,
}
}
// Error returns the conflict error
func (e ConflictError) Error() string {
return e.Err.Error()
}
// CustomError is a custom error
type CustomError struct {
Err error
+1
View File
@@ -441,6 +441,7 @@ func main() {
MicrosoftDeviceCode: services.MicrosoftDeviceCode,
OptionService: services.Option,
RecipientService: services.Recipient,
ScimService: services.Scim,
Logger: logger,
}
+22
View File
@@ -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
+33
View File
@@ -0,0 +1,33 @@
package model
import (
"time"
"github.com/google/uuid"
"github.com/oapi-codegen/nullable"
)
// CompanyScimConfig is the SCIM configuration for a company
type CompanyScimConfig struct {
ID nullable.Nullable[uuid.UUID] `json:"id"`
CreatedAt *time.Time `json:"createdAt"`
UpdatedAt *time.Time `json:"updatedAt"`
CompanyID nullable.Nullable[uuid.UUID] `json:"companyID"`
TokenPrefix nullable.Nullable[string] `json:"tokenPrefix"` // shown to user for identification
Enabled bool `json:"enabled"`
LastSyncAt *time.Time `json:"lastSyncAt"`
// Token is only set when a new token has just been generated (never persisted directly)
Token string `json:"token,omitempty"`
}
// Validate checks if the config is valid
func (c *CompanyScimConfig) Validate() error {
return nil
}
// ToDBMap converts the fields for persistence
func (c *CompanyScimConfig) ToDBMap() map[string]any {
m := map[string]any{}
m["enabled"] = c.Enabled
return m
}
+12
View File
@@ -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
+38
View File
@@ -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(
+275
View File
@@ -0,0 +1,275 @@
package repository
import (
"context"
"fmt"
"time"
"github.com/google/uuid"
"github.com/oapi-codegen/nullable"
"github.com/phishingclub/phishingclub/database"
"github.com/phishingclub/phishingclub/errs"
"github.com/phishingclub/phishingclub/model"
"gorm.io/gorm"
)
// CompanyScimConfig is the repository for company SCIM configuration
type CompanyScimConfig struct {
DB *gorm.DB
}
// Insert inserts a new company SCIM config row with a pre-computed bcrypt token hash
func (r *CompanyScimConfig) Insert(
ctx context.Context,
config *model.CompanyScimConfig,
tokenHash string,
) (*uuid.UUID, error) {
id := uuid.New()
row := map[string]any{}
row["id"] = id
AddTimestamps(row)
row["token_hash"] = tokenHash
if companyID, err := config.CompanyID.Get(); err == nil {
row["company_id"] = companyID.String()
} else {
return nil, fmt.Errorf("company_id is required")
}
if tokenPrefix, err := config.TokenPrefix.Get(); err == nil {
row["token_prefix"] = tokenPrefix
} else {
row["token_prefix"] = ""
}
row["enabled"] = config.Enabled
res := r.DB.
Model(&database.CompanyScimConfig{}).
Create(row)
if res.Error != nil {
return nil, errs.Wrap(res.Error)
}
return &id, nil
}
// GetByCompanyID fetches the SCIM config for a given company
func (r *CompanyScimConfig) GetByCompanyID(
ctx context.Context,
companyID *uuid.UUID,
) (*model.CompanyScimConfig, error) {
var row database.CompanyScimConfig
res := r.DB.
Where(
fmt.Sprintf(
"%s = ?",
TableColumn(database.COMPANY_SCIM_CONFIG_TABLE, "company_id"),
),
companyID.String(),
).
First(&row)
if res.Error != nil {
return nil, errs.Wrap(res.Error)
}
return ToCompanyScimConfig(&row), nil
}
// GetByID fetches the SCIM config by its primary key
func (r *CompanyScimConfig) GetByID(
ctx context.Context,
id *uuid.UUID,
) (*model.CompanyScimConfig, error) {
var row database.CompanyScimConfig
res := r.DB.
Where(
fmt.Sprintf(
"%s = ?",
TableColumnID(database.COMPANY_SCIM_CONFIG_TABLE),
),
id.String(),
).
First(&row)
if res.Error != nil {
return nil, errs.Wrap(res.Error)
}
return ToCompanyScimConfig(&row), nil
}
// UpdateByID performs a partial update on the SCIM config via ToDBMap
func (r *CompanyScimConfig) UpdateByID(
ctx context.Context,
id *uuid.UUID,
config *model.CompanyScimConfig,
) error {
row := config.ToDBMap()
AddUpdatedAt(row)
res := r.DB.
Model(&database.CompanyScimConfig{}).
Where(
fmt.Sprintf(
"%s = ?",
TableColumnID(database.COMPANY_SCIM_CONFIG_TABLE),
),
id.String(),
).
Updates(row)
if res.Error != nil {
return errs.Wrap(res.Error)
}
return nil
}
// UpdateTokenByID replaces the token hash and prefix and bumps updated_at
func (r *CompanyScimConfig) UpdateTokenByID(
ctx context.Context,
id *uuid.UUID,
tokenHash string,
tokenPrefix string,
) error {
row := map[string]any{
"token_hash": tokenHash,
"token_prefix": tokenPrefix,
}
AddUpdatedAt(row)
res := r.DB.
Model(&database.CompanyScimConfig{}).
Where(
fmt.Sprintf(
"%s = ?",
TableColumnID(database.COMPANY_SCIM_CONFIG_TABLE),
),
id.String(),
).
Updates(row)
if res.Error != nil {
return errs.Wrap(res.Error)
}
return nil
}
// UpdateLastSyncAt sets last_sync_at to the current UTC time for the given config ID
func (r *CompanyScimConfig) UpdateLastSyncAt(
ctx context.Context,
id *uuid.UUID,
) error {
now := time.Now().UTC()
row := map[string]any{
"last_sync_at": now,
}
AddUpdatedAt(row)
res := r.DB.
Model(&database.CompanyScimConfig{}).
Where(
fmt.Sprintf(
"%s = ?",
TableColumnID(database.COMPANY_SCIM_CONFIG_TABLE),
),
id.String(),
).
Updates(row)
if res.Error != nil {
return errs.Wrap(res.Error)
}
return nil
}
// GetTokenHashByCompanyID fetches only the token hash for the given company,
// used during bearer-token verification so the hash never leaks into the model layer
func (r *CompanyScimConfig) GetTokenHashByCompanyID(
ctx context.Context,
companyID *uuid.UUID,
) (string, error) {
var row database.CompanyScimConfig
res := r.DB.
Select("token_hash").
Where(
fmt.Sprintf(
"%s = ?",
TableColumn(database.COMPANY_SCIM_CONFIG_TABLE, "company_id"),
),
companyID.String(),
).
First(&row)
if res.Error != nil {
return "", errs.Wrap(res.Error)
}
return row.TokenHash, nil
}
// GetWithTokenHashByCompanyID fetches the full config row and the token hash in a
// single query, used during bearer-token verification.
func (r *CompanyScimConfig) GetWithTokenHashByCompanyID(
ctx context.Context,
companyID *uuid.UUID,
) (*model.CompanyScimConfig, string, error) {
var row database.CompanyScimConfig
res := r.DB.
Where(
fmt.Sprintf(
"%s = ?",
TableColumn(database.COMPANY_SCIM_CONFIG_TABLE, "company_id"),
),
companyID.String(),
).
First(&row)
if res.Error != nil {
return nil, "", errs.Wrap(res.Error)
}
return ToCompanyScimConfig(&row), row.TokenHash, nil
}
// DeleteByCompanyID removes the SCIM config for a given company
func (r *CompanyScimConfig) DeleteByCompanyID(
ctx context.Context,
companyID *uuid.UUID,
) error {
res := r.DB.
Where(
fmt.Sprintf(
"%s = ?",
TableColumn(database.COMPANY_SCIM_CONFIG_TABLE, "company_id"),
),
companyID.String(),
).
Delete(&database.CompanyScimConfig{})
if res.Error != nil {
return errs.Wrap(res.Error)
}
return nil
}
// ToCompanyScimConfig maps a database row to the business model.
// the token field is intentionally left empty — it is never read back from storage.
func ToCompanyScimConfig(row *database.CompanyScimConfig) *model.CompanyScimConfig {
id := nullable.NewNullableWithValue(*row.ID)
companyID := nullable.NewNullNullable[uuid.UUID]()
if row.CompanyID != nil {
companyID.Set(*row.CompanyID)
}
tokenPrefix := nullable.NewNullableWithValue(row.TokenPrefix)
return &model.CompanyScimConfig{
ID: id,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
CompanyID: companyID,
TokenPrefix: tokenPrefix,
Enabled: row.Enabled,
LastSyncAt: row.LastSyncAt,
}
}
+124 -17
View File
@@ -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
}
+41
View File
@@ -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
+1
View File
@@ -58,6 +58,7 @@ func initialInstallAndSeed(
&database.OAuthProvider{},
&database.OAuthState{},
&database.MicrosoftDeviceCode{},
&database.CompanyScimConfig{},
&database.RemoteBrowser{},
&database.ReportTemplate{},
}
+22 -1
View File
@@ -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")
+267
View File
@@ -0,0 +1,267 @@
package service
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"github.com/go-errors/errors"
"github.com/google/uuid"
"github.com/oapi-codegen/nullable"
"github.com/phishingclub/phishingclub/data"
"github.com/phishingclub/phishingclub/errs"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/repository"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
// CompanyScimConfig is the service for SCIM configuration per company
type CompanyScimConfig struct {
Common
CompanyScimConfigRepository *repository.CompanyScimConfig
}
// generateToken creates a cryptographically random 32-byte token and returns
// the hex-encoded plain token and its 8-character prefix
func generateScimToken() (plain string, prefix string, err error) {
buf := make([]byte, 32)
if _, err = rand.Read(buf); err != nil {
return "", "", fmt.Errorf("failed to generate random token: %w", err)
}
plain = hex.EncodeToString(buf)
prefix = plain[:8]
return plain, prefix, nil
}
// hashScimToken bcrypt-hashes the plain token with cost 12
func hashScimToken(plain string) (string, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(plain), 12)
if err != nil {
return "", fmt.Errorf("failed to hash token: %w", err)
}
return string(hash), nil
}
// GetByCompanyID returns the SCIM config for the given company
func (s *CompanyScimConfig) GetByCompanyID(
ctx context.Context,
session *model.Session,
companyID *uuid.UUID,
) (*model.CompanyScimConfig, error) {
ae := NewAuditEvent("CompanyScimConfig.GetByCompanyID", session)
ae.Details["companyID"] = companyID.String()
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil {
s.LogAuthError(err)
return nil, errs.Wrap(err)
}
if !isAuthorized {
s.AuditLogNotAuthorized(ae)
return nil, errs.ErrAuthorizationFailed
}
// get
config, err := s.CompanyScimConfigRepository.GetByCompanyID(ctx, companyID)
if err != nil {
s.Logger.Errorw("failed to get scim config by company id", "error", err)
return nil, errs.Wrap(err)
}
// no audit on read
return config, nil
}
// Upsert creates or updates the SCIM configuration for the given company.
// when creating, a new token is generated and returned in plain text (shown once).
// when updating, the token is NOT rotated.
func (s *CompanyScimConfig) Upsert(
ctx context.Context,
session *model.Session,
companyID *uuid.UUID,
enabled bool,
) (*model.CompanyScimConfig, error) {
ae := NewAuditEvent("CompanyScimConfig.Upsert", session)
ae.Details["companyID"] = companyID.String()
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil {
s.LogAuthError(err)
return nil, errs.Wrap(err)
}
if !isAuthorized {
s.AuditLogNotAuthorized(ae)
return nil, errs.ErrAuthorizationFailed
}
// check if a config already exists for this company
existing, err := s.CompanyScimConfigRepository.GetByCompanyID(ctx, companyID)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
s.Logger.Errorw("failed to look up existing scim config", "error", err)
return nil, errs.Wrap(err)
}
if existing != nil {
// update enabled and linked group only; do not regenerate the token
existing.Enabled = enabled
existingID, idErr := existing.ID.Get()
if idErr != nil {
s.Logger.Errorw("scim config has no id", "error", idErr)
return nil, errs.Wrap(idErr)
}
if err := s.CompanyScimConfigRepository.UpdateByID(ctx, &existingID, existing); err != nil {
s.Logger.Errorw("failed to update scim config", "error", err)
return nil, errs.Wrap(err)
}
ae.Details["id"] = existingID.String()
ae.Details["action"] = "update"
s.AuditLogAuthorized(ae)
return existing, nil
}
// create: generate a fresh token
plain, prefix, err := generateScimToken()
if err != nil {
s.Logger.Errorw("failed to generate scim token", "error", err)
return nil, errs.Wrap(err)
}
tokenHash, err := hashScimToken(plain)
if err != nil {
s.Logger.Errorw("failed to hash scim token", "error", err)
return nil, errs.Wrap(err)
}
config := &model.CompanyScimConfig{
CompanyID: nullable.NewNullableWithValue(*companyID),
Enabled: enabled,
TokenPrefix: nullable.NewNullableWithValue(prefix),
}
id, err := s.CompanyScimConfigRepository.Insert(ctx, config, tokenHash)
if err != nil {
s.Logger.Errorw("failed to insert scim config", "error", err)
return nil, errs.Wrap(err)
}
ae.Details["id"] = id.String()
ae.Details["action"] = "insert"
s.AuditLogAuthorized(ae)
// load the persisted record and attach the plain token for one-time display
created, err := s.CompanyScimConfigRepository.GetByID(ctx, id)
if err != nil {
s.Logger.Errorw("failed to fetch newly inserted scim config", "error", err)
return nil, errs.Wrap(err)
}
// set plain token on model for one-time display; it is never persisted
created.Token = plain
return created, nil
}
// RotateToken generates a new token for the existing SCIM config identified by
// companyID. the plain token is returned once so the caller can present it to
// the user; only the hash is persisted.
func (s *CompanyScimConfig) RotateToken(
ctx context.Context,
session *model.Session,
companyID *uuid.UUID,
) (*model.CompanyScimConfig, error) {
ae := NewAuditEvent("CompanyScimConfig.RotateToken", session)
ae.Details["companyID"] = companyID.String()
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil {
s.LogAuthError(err)
return nil, errs.Wrap(err)
}
if !isAuthorized {
s.AuditLogNotAuthorized(ae)
return nil, errs.ErrAuthorizationFailed
}
// fetch existing config
config, err := s.CompanyScimConfigRepository.GetByCompanyID(ctx, companyID)
if err != nil {
s.Logger.Errorw("failed to get scim config for token rotation", "error", err)
return nil, errs.Wrap(err)
}
configID, idErr := config.ID.Get()
if idErr != nil {
s.Logger.Errorw("scim config has no id", "error", idErr)
return nil, errs.Wrap(idErr)
}
// generate a new token
plain, prefix, err := generateScimToken()
if err != nil {
s.Logger.Errorw("failed to generate new scim token", "error", err)
return nil, errs.Wrap(err)
}
tokenHash, err := hashScimToken(plain)
if err != nil {
s.Logger.Errorw("failed to hash new scim token", "error", err)
return nil, errs.Wrap(err)
}
if err := s.CompanyScimConfigRepository.UpdateTokenByID(ctx, &configID, tokenHash, prefix); err != nil {
s.Logger.Errorw("failed to update scim token", "error", err)
return nil, errs.Wrap(err)
}
ae.Details["id"] = configID.String()
s.AuditLogAuthorized(ae)
// reload and return with plain token set for one-time display
updated, err := s.CompanyScimConfigRepository.GetByID(ctx, &configID)
if err != nil {
s.Logger.Errorw("failed to fetch scim config after token rotation", "error", err)
return nil, errs.Wrap(err)
}
// set plain token on model for one-time display; it is never persisted
updated.Token = plain
return updated, nil
}
// DeleteByCompanyID removes the SCIM configuration for the given company
func (s *CompanyScimConfig) DeleteByCompanyID(
ctx context.Context,
session *model.Session,
companyID *uuid.UUID,
) error {
ae := NewAuditEvent("CompanyScimConfig.DeleteByCompanyID", session)
ae.Details["companyID"] = companyID.String()
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil {
s.LogAuthError(err)
return errs.Wrap(err)
}
if !isAuthorized {
s.AuditLogNotAuthorized(ae)
return errs.ErrAuthorizationFailed
}
// delete
if err := s.CompanyScimConfigRepository.DeleteByCompanyID(ctx, companyID); err != nil {
s.Logger.Errorw("failed to delete scim config", "error", err)
return errs.Wrap(err)
}
s.AuditLogAuthorized(ae)
return nil
}
// VerifyToken checks whether the supplied plain token matches the stored bcrypt
// hash for the given company. no session or permission check is performed
// because this is called by the inbound SCIM handler during bearer-token auth.
func (s *CompanyScimConfig) VerifyToken(
ctx context.Context,
companyID *uuid.UUID,
plainToken string,
) (bool, *model.CompanyScimConfig, error) {
// fetch config and token hash in a single round-trip
config, tokenHash, err := s.CompanyScimConfigRepository.GetWithTokenHashByCompanyID(ctx, companyID)
if err != nil {
s.Logger.Errorw("failed to get scim config for token verification", "error", err)
return false, nil, errs.Wrap(err)
}
if err := bcrypt.CompareHashAndPassword([]byte(tokenHash), []byte(plainToken)); err != nil {
if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) {
return false, config, nil
}
s.Logger.Errorw("failed to compare scim token hash", "error", err)
return false, config, errs.Wrap(err)
}
return true, config, nil
}
+18
View File
@@ -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,
+201
View File
@@ -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
}
+5 -3
View File
@@ -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
+1
View File
@@ -254,6 +254,7 @@ func (r *RecipientGroup) GetAll(
r.AuditLogNotAuthorized(ae)
return result, errs.ErrAuthorizationFailed
}
// get recipient groups
result, err = r.RecipientGroupRepository.GetAll(
ctx,
File diff suppressed because it is too large. Load diff
+5
View File
@@ -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)
})
+35
View File
@@ -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
}
+304
View File
@@ -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
}
+159
View File
@@ -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
}
+99
View File
@@ -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:])
}
+199
View File
@@ -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,
}
+2
View File
@@ -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
+65
View File
@@ -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<ApiResponse>}
*/
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<ApiResponse>}
*/
setScimDomain: async (domain) => {
return await postJSON(this.getPath(`/option/scim-domain`), { domain });
},
/**
* Get the SCIM soft-delete retention window in days.
*
* @returns {Promise<ApiResponse>}
*/
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<ApiResponse>}
*/
setScimRetentionDays: async (days) => {
return await postJSON(this.getPath(`/option/scim-retention-days`), { days });
},
/**
* Set setting by key and value.
*
+28 -17
View File
@@ -1,21 +1,32 @@
import { API } from '$lib/api/api.js';
import { immediateResponseHandler } from '$lib/api/middleware';
// api singleton where each response is proxyed to the responseHandler
export const api = new Proxy(API.instance, {
get: function(target, prop) {
const apiSection = target[prop];
const apiSectionProxy = new Proxy(apiSection, {
get: function(target, prop) {
const method = new Proxy(target[prop], {
apply: async (target, _, argumentsList) => {
return immediateResponseHandler(await target(...argumentsList));
}
});
return method
}
});
return apiSectionProxy;
}
});
// wrap a single async function with the response handler
const wrapMethod = (fn) =>
new Proxy(fn, {
apply: async (target, _, argumentsList) => {
return immediateResponseHandler(await target(...argumentsList));
}
});
// wrap a section (one level of methods, possibly with nested sub-objects)
const wrapSection = (section) =>
new Proxy(section, {
get: function (target, prop) {
const value = target[prop];
// if the value is a plain object (nested sub-section like scim), wrap it recursively
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
return wrapSection(value);
}
// otherwise it is a method — wrap it with the response handler
return wrapMethod(value);
}
});
// api singleton where each response is proxied to the responseHandler
export const api = new Proxy(API.instance, {
get: function (target, prop) {
const apiSection = target[prop];
return wrapSection(apiSection);
}
});
@@ -0,0 +1,429 @@
<script>
import { addToast } from '$lib/store/toast';
import Modal from '../Modal.svelte';
import Alert from '../Alert.svelte';
import { api } from '$lib/api/apiProxy.js';
// external
export let visible = false;
/** @type {{ id: string, name: string } | null} */
export let company = null;
// local state
let scimConfig = null;
// the global domain SCIM is served on; SCIM lives on the phishing server, not
// this admin origin, so the base URL must use the configured domain
let scimDomain = '';
let isLoading = false;
let isTogglingEnabled = false;
let isRotating = false;
let isSettingUp = false;
let isDeleting = false;
let isPruning = false;
// token reveal — only populated immediately after create or rotate
let revealedToken = '';
let showTokenReveal = false;
// confirmation dialogs
let isRotateAlertVisible = false;
let isDeleteAlertVisible = false;
let isPruneAlertVisible = false;
// reactive: reload when modal opens
$: {
if (visible && company) {
loadAll();
}
}
// reactive: clean up when modal closes
$: {
if (!visible) {
resetState();
}
}
const resetState = () => {
scimConfig = null;
revealedToken = '';
showTokenReveal = false;
isRotateAlertVisible = false;
isDeleteAlertVisible = false;
isPruneAlertVisible = false;
};
const loadAll = async () => {
isLoading = true;
try {
await Promise.all([loadScimConfig(), loadScimDomain()]);
} finally {
isLoading = false;
}
};
const loadScimDomain = async () => {
try {
const res = await api.option.getScimDomain();
if (res.success) {
scimDomain = res.data.domain || '';
}
} catch (err) {
console.error('failed to load SCIM domain', err);
}
};
const loadScimConfig = async () => {
try {
const res = await api.company.scim.getByCompanyID(company.id);
if (res && res.success && res.data) {
scimConfig = res.data;
} else {
scimConfig = null;
}
} catch (e) {
console.error('failed to load scim config', e);
scimConfig = null;
}
};
// called once — creates the config and reveals the token
const onSetUp = async () => {
isSettingUp = true;
try {
const res = await api.company.scim.upsert(company.id, { enabled: true });
if (!res || !res.success) {
addToast(res?.error ?? 'Failed to set up SCIM', 'Error');
return;
}
scimConfig = res.data;
if (res.data?.token) {
revealedToken = res.data.token;
showTokenReveal = true;
}
addToast('SCIM set up', 'Success');
} catch (e) {
console.error('failed to set up scim', e);
addToast('Failed to set up SCIM', 'Error');
} finally {
isSettingUp = false;
}
};
// inline toggle — immediately persists the new enabled state
const onToggleEnabled = async () => {
if (!scimConfig) return;
isTogglingEnabled = true;
const newEnabled = !scimConfig.enabled;
try {
const res = await api.company.scim.upsert(company.id, { enabled: newEnabled });
if (!res || !res.success) {
addToast(res?.error ?? 'Failed to update SCIM', 'Error');
return;
}
scimConfig = res.data;
addToast(newEnabled ? 'SCIM enabled' : 'SCIM disabled', 'Success');
} catch (e) {
console.error('failed to toggle scim enabled', e);
addToast('Failed to update SCIM', 'Error');
} finally {
isTogglingEnabled = false;
}
};
const onConfirmRotateToken = async () => {
isRotating = true;
try {
const res = await api.company.scim.rotateToken(company.id);
if (!res || !res.success) {
addToast(res?.error ?? 'Failed to rotate token', 'Error');
return { success: false };
}
scimConfig = res.data;
revealedToken = res.data?.token ?? '';
showTokenReveal = true;
addToast('SCIM token rotated', 'Success');
return { success: true };
} catch (e) {
console.error('failed to rotate scim token', e);
addToast('Failed to rotate SCIM token', 'Error');
return { success: false };
} finally {
isRotating = false;
}
};
const onConfirmDelete = async () => {
isDeleting = true;
try {
const res = await api.company.scim.delete(company.id);
if (!res || !res.success) {
addToast(res?.error ?? 'Failed to delete SCIM config', 'Error');
return { success: false };
}
scimConfig = null;
showTokenReveal = false;
revealedToken = '';
addToast('SCIM config deleted', 'Success');
return { success: true };
} catch (e) {
console.error('failed to delete scim config', e);
addToast('Failed to delete SCIM config', 'Error');
return { success: false };
} finally {
isDeleting = false;
}
};
const onConfirmPrune = async () => {
isPruning = true;
try {
const res = await api.company.scim.prune(company.id);
if (res && res.success) {
const n = res.data?.pruned ?? 0;
addToast(`Removed ${n} disabled ${n === 1 ? 'recipient' : 'recipients'}`, 'Success');
return { success: true };
}
addToast(res?.error ?? 'Failed to prune disabled recipients', 'Error');
return { success: false };
} catch (e) {
console.error('failed to prune scim users', e);
addToast('Failed to prune disabled recipients', 'Error');
return { success: false };
} finally {
isPruning = false;
}
};
const copyToClipboard = async (text) => {
try {
await navigator.clipboard.writeText(text);
addToast('Copied to clipboard', 'Success');
} catch (e) {
console.error('failed to copy to clipboard', e);
}
};
const dismissToken = () => {
showTokenReveal = false;
revealedToken = '';
};
const formatDate = (dateStr) => {
if (!dateStr) return 'Never';
try {
return new Date(dateStr).toLocaleString();
} catch {
return dateStr;
}
};
$: scimBaseURL =
company && scimDomain ? `https://${scimDomain}/api/v1/scim/v2/${company.id}` : '';
$: isBusy = isSettingUp || isTogglingEnabled || isRotating || isDeleting || isPruning;
</script>
<Modal headerText={`SCIM`} bind:visible>
<div class="w-[600px] p-6 space-y-6">
{#if isLoading}
<div class="flex items-center justify-center py-10">
<p class="text-gray-500 dark:text-gray-400 transition-colors duration-200">Loading...</p>
</div>
{:else if showTokenReveal && revealedToken}
<!-- ── step 2: token reveal — nothing else until dismissed ── -->
<div
class="rounded-md border border-amber-400 dark:border-amber-500/60 bg-amber-50 dark:bg-amber-900/20 p-4 space-y-3 transition-colors duration-200"
>
<p class="text-sm font-semibold text-amber-700 dark:text-amber-400">
⚠ Copy this token now - it will not be shown again.
</p>
<div class="flex items-center gap-2">
<input
type="text"
readonly
value={revealedToken}
class="flex-1 px-3 py-2 text-sm rounded-md bg-white dark:bg-gray-900/60 border border-amber-300 dark:border-amber-600/60 text-gray-800 dark:text-gray-200 font-mono focus:outline-none transition-colors duration-200"
/>
<button
type="button"
on:click={() => copyToClipboard(revealedToken)}
class="px-3 py-2 text-sm bg-amber-500 hover:bg-amber-400 dark:bg-amber-600/80 dark:hover:bg-amber-500/80 text-white rounded-md transition-colors duration-200"
>
Copy
</button>
</div>
<button
type="button"
class="text-xs text-amber-600 dark:text-amber-500 underline hover:no-underline"
on:click={dismissToken}
>
I have copied the token, dismiss
</button>
</div>
{:else if !scimConfig}
<!-- ── step 1: no config yet ── -->
<p class="text-sm text-gray-500 dark:text-gray-400 transition-colors duration-200">
Set up SCIM provisioning for <strong class="text-gray-700 dark:text-gray-300"
>{company?.name}</strong
>. <br /> A bearer token will be generated once and must be copied into your identity provider.
</p>
<div class="flex justify-end">
<button
type="button"
disabled={isBusy}
on:click={onSetUp}
class="bg-cta-blue dark:bg-highlight-blue/80 hover:bg-blue-700 dark:hover:bg-highlight-blue text-sm uppercase font-bold px-4 py-2 text-white rounded-md disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
>
{isSettingUp ? 'Setting up...' : 'Configure SCIM'}
</button>
</div>
{:else}
<!-- ── step 3: config exists ── -->
<!-- status panel -->
<div
class="rounded-md border border-gray-200 dark:border-gray-700/60 p-4 space-y-3 transition-colors duration-200"
>
<div class="grid grid-cols-2 gap-x-6 gap-y-2 text-sm">
<span class="text-gray-500 dark:text-gray-500">Token prefix</span>
<span class="text-gray-800 dark:text-gray-200 font-mono">
{scimConfig.tokenPrefix ? scimConfig.tokenPrefix + '...' : '—'}
</span>
<span class="text-gray-500 dark:text-gray-500">Last sync</span>
<span class="text-gray-800 dark:text-gray-200">{formatDate(scimConfig.lastSyncAt)}</span>
</div>
<!-- scim base url -->
<div class="pt-3 border-t border-gray-200 dark:border-gray-700/60 space-y-1">
<p class="text-xs font-semibold text-gray-500 dark:text-gray-400">
SCIM Base URL - provide this to your identity provider
</p>
{#if scimDomain}
<div class="flex items-center gap-2">
<input
type="text"
readonly
value={scimBaseURL}
class="flex-1 px-3 py-2 text-xs rounded-md bg-gray-50 dark:bg-gray-900/60 border border-gray-200 dark:border-gray-700/60 text-gray-700 dark:text-gray-300 font-mono focus:outline-none transition-colors duration-200"
/>
<button
type="button"
on:click={() => copyToClipboard(scimBaseURL)}
class="px-3 py-2 text-sm bg-slate-500 hover:bg-slate-400 dark:bg-gray-700/80 dark:hover:bg-gray-600/80 text-white rounded-md transition-colors duration-200"
>
Copy
</button>
</div>
{:else}
<p class="text-xs text-amber-600 dark:text-amber-400">
No SCIM domain is configured. Set a global SCIM domain under Settings → System before
connecting an identity provider.
</p>
{/if}
</div>
</div>
<!-- enabled toggle -->
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">Provisioning</p>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{scimConfig.enabled
? 'Active - IdP can push changes.'
: 'Paused - incoming SCIM requests will be rejected.'}
</p>
</div>
<button
type="button"
role="switch"
aria-checked={scimConfig.enabled}
disabled={isBusy}
on:click={onToggleEnabled}
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed
{scimConfig.enabled ? 'bg-cta-blue dark:bg-highlight-blue/80' : 'bg-gray-300 dark:bg-gray-600'}"
>
<span
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200
{scimConfig.enabled ? 'translate-x-5' : 'translate-x-0'}"
/>
</button>
</div>
<!-- actions -->
<div
class="border-t border-gray-200 dark:border-gray-700/60 pt-4 flex gap-3 justify-end transition-colors duration-200"
>
<button
type="button"
disabled={isBusy}
on:click={() => (isPruneAlertVisible = true)}
title="Permanently remove this company's disabled recipients now"
class="bg-slate-400 dark:bg-gray-700/80 hover:bg-slate-300 dark:hover:bg-gray-600/80 text-sm uppercase font-bold px-4 py-2 text-white rounded-md disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
>
{isPruning ? 'Pruning...' : 'Prune Removed Users'}
</button>
<button
type="button"
disabled={isBusy}
on:click={() => (isRotateAlertVisible = true)}
class="bg-slate-400 dark:bg-gray-700/80 hover:bg-slate-300 dark:hover:bg-gray-600/80 text-sm uppercase font-bold px-4 py-2 text-white rounded-md disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
>
Rotate Token
</button>
<button
type="button"
disabled={isBusy}
on:click={() => (isDeleteAlertVisible = true)}
class="bg-red-600 dark:bg-red-700/80 hover:bg-red-500 dark:hover:bg-red-600/80 text-sm uppercase font-bold px-4 py-2 text-white rounded-md disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
>
Delete
</button>
</div>
{/if}
</div>
</Modal>
<!-- rotate token confirmation -->
<Alert
headline="Rotate SCIM Token"
bind:visible={isRotateAlertVisible}
onConfirm={onConfirmRotateToken}
>
<p>
Are you sure you want to rotate the SCIM bearer token for
<strong>{company?.name}</strong>?
</p>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
the existing token will be immediately invalidated. your identity provider must be updated with
the new token before provisioning can resume.
</p>
</Alert>
<!-- prune disabled recipients confirmation -->
<Alert headline="Remove disabled recipients" bind:visible={isPruneAlertVisible} onConfirm={onConfirmPrune}>
<p>
Permanently remove all <strong>disabled</strong> recipients for
<strong>{company?.name}</strong> now?
</p>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
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.
</p>
</Alert>
<!-- delete config confirmation -->
<Alert
headline="Delete SCIM Config"
bind:visible={isDeleteAlertVisible}
onConfirm={onConfirmDelete}
>
<p>
Are you sure you want to delete the SCIM configuration for
<strong>{company?.name}</strong>?
</p>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
the bearer token will be invalidated and future syncs will stop. previously provisioned
recipients will not be removed.
</p>
</Alert>
@@ -1,5 +1,15 @@
<script>
// dimmed visually mutes the row (e.g. for SCIM-disabled recipients)
export let dimmed = false;
// title is shown as a native hover tooltip on the row
export let title = '';
</script>
<tr
class="table-row odd:bg-white odd:dark:bg-gray-900/40 even:bg-pleasant-gray even:dark:bg-gray-800/30 h-10 hover:bg-cta-light-blue hover:dark:bg-highlight-blue/20 transition-colors duration-200"
{title}
class="table-row odd:bg-white odd:dark:bg-gray-900/40 even:bg-pleasant-gray even:dark:bg-gray-800/30 h-10 hover:bg-cta-light-blue hover:dark:bg-highlight-blue/20 transition-colors duration-200 {dimmed
? 'opacity-50'
: ''}"
>
<slot />
</tr>
+13 -1
View File
@@ -2784,12 +2784,24 @@
<div class="space-y-1">
{#each recipients as recipient}
<div
class="flex items-center justify-between py-2 px-3 rounded hover:bg-white dark:hover:bg-gray-700/50 transition-colors"
class="flex items-center justify-between py-2 px-3 rounded hover:bg-white dark:hover:bg-gray-700/50 transition-colors {recipient.scimSoftDeletedAt
? 'opacity-50'
: ''}"
title={recipient.scimSoftDeletedAt
? 'Disabled in the identity provider; excluded from this campaign'
: ''}
>
<span
class="text-sm text-gray-900 dark:text-gray-100 font-medium truncate flex-1"
>
{recipient.email}
{#if recipient.scimSoftDeletedAt}
<span
class="ml-2 inline-block rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase bg-gray-200 text-gray-600 dark:bg-gray-700 dark:text-gray-300"
>
Disabled
</span>
{/if}
</span>
{#if recipient.firstName || recipient.lastName}
<span
+29 -10
View File
@@ -2073,7 +2073,12 @@
isGhost={isRecipientTableLoading}
>
{#each campaignRecipients as recp (recp.id)}
<TableRow>
<TableRow
dimmed={!!recp.recipient?.scimSoftDeletedAt}
title={recp.recipient?.scimSoftDeletedAt
? 'Disabled: deprovisioned in the identity provider. Pending sends were cancelled; the row is kept for history until the recipient is pruned.'
: ''}
>
{#if liveSessions.size > 0}
<TableCell>
{#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}
<span class="inline-flex items-center gap-2">
<span class="truncate">{recp.recipient.email}</span>
{#if recp.recipient.scimSoftDeletedAt}
<span
class="inline-block rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase bg-gray-200 text-gray-600 dark:bg-gray-700 dark:text-gray-300"
>
Disabled
</span>
{/if}
</span>
</button>
{/if}
</TableCell>
@@ -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()}
/>
<TableUpdateButton
name="Copy lure URL"
+17
View File
@@ -22,6 +22,7 @@
import DeleteAlert from '$lib/components/modal/DeleteAlert.svelte';
import TableDropDownButton from '$lib/components/table/TableDropDownButton.svelte';
import Alert from '$lib/components/Alert.svelte';
import ScimModal from '$lib/components/modal/ScimModal.svelte';
import Editor from '$lib/components/editor/Editor.svelte';
import FormGrid from '$lib/components/FormGrid.svelte';
@@ -57,6 +58,9 @@
let isExportSharedModalVisible = false;
let exportCompany = null;
let isScimModalVisible = false;
let scimCompany = null;
let isCompanyReportTemplateModalVisible = false;
let companyReportTemplateContent = '';
let companyReportTemplateID = null;
@@ -298,6 +302,16 @@
isExportSharedModalVisible = false;
};
const openScimModal = (company) => {
scimCompany = company;
isScimModalVisible = true;
};
const closeScimModal = () => {
isScimModalVisible = false;
scimCompany = null;
};
const onConfirmExportCompany = async () => {
try {
showIsLoading();
@@ -448,6 +462,7 @@
on:click={() => openViewCommentModal(company)}
/>
<TableDropDownButton name="Export" on:click={() => openExportCompanyModal(company)} />
<TableDropDownButton name="SCIM" on:click={() => openScimModal(company)} />
<TableDropDownButton
name="Custom Stats"
on:click={() => goto(`/company/${company.id}/stats`)}
@@ -630,6 +645,8 @@
</div>
</Alert>
<ScimModal bind:visible={isScimModalVisible} company={scimCompany} />
<Alert
headline="Export Shared Data"
bind:visible={isExportSharedModalVisible}
+13 -1
View File
@@ -462,11 +462,23 @@
pagination={tableURLParams}
>
{#each recipients as recipient}
<TableRow>
<TableRow
dimmed={!!recipient.scimSoftDeletedAt}
title={recipient.scimSoftDeletedAt
? 'Disabled: deprovisioned in the identity provider. Excluded from campaigns and email delivery, pending removal.'
: ''}
>
<TableCellLink href={`/recipient/${recipient.id}`} title={recipient.email}>
{#if recipient.email}
{recipient.email}
{/if}
{#if recipient.scimSoftDeletedAt}
<span
class="ml-2 inline-block rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase align-middle bg-gray-200 text-gray-600 dark:bg-gray-700 dark:text-gray-300"
>
Disabled
</span>
{/if}
</TableCellLink>
<TableCell>
{#if recipient.firstName}
@@ -362,6 +362,13 @@
<DynamicLabel />
{/if}
{group.name}
{#if group.scimEnabled}
<span
class="ml-2 inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300"
>
SCIM
</span>
{/if}
</TableCellLink>
<TableCellLink href={`/recipient/group/${group.id}`} title={group.recipientCount}>
@@ -462,8 +462,24 @@
isGhost={isTableLoading}
>
{#each recipients as recipient}
<TableRow>
<TableCell value={recipient.email} />
<TableRow
dimmed={!!recipient.scimSoftDeletedAt}
title={recipient.scimSoftDeletedAt
? 'Disabled: deprovisioned in the identity provider. Excluded from campaigns and email delivery, pending removal.'
: ''}
>
<TableCell>
<span class="inline-flex items-center gap-2">
<span class="truncate">{recipient.email ?? ''}</span>
{#if recipient.scimSoftDeletedAt}
<span
class="inline-block rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase bg-gray-200 text-gray-600 dark:bg-gray-700 dark:text-gray-300"
>
Disabled
</span>
{/if}
</span>
</TableCell>
<TableCellLink href="/recipient/{recipient.id}" title={recipient.firstName}>
{recipient.firstName}
</TableCellLink>
@@ -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}
</svelte:fragment>
</SettingsCard>
<SettingsCard title="SCIM Provisioning">
<p class="text-gray-600 dark:text-gray-300 text-sm transition-colors duration-200">
Global domain that serves SCIM provisioning. Must be publicly reachable on 443 with a valid
certificate; prefer a dedicated domain not used for campaigns.
</p>
<Form>
<TextFieldSelect
id="scimDomain"
bind:value={scimDomain}
onSelect={setScimDomain}
options={scimDomainOptions}>SCIM domain</TextFieldSelect
>
</Form>
<p class="mt-4 text-gray-600 dark:text-gray-300 text-sm transition-colors duration-200">
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.
</p>
<div class="flex items-center gap-2">
<input
type="number"
min="0"
bind:value={scimRetentionDays}
on:change={setScimRetention}
class="w-28 px-3 py-2 text-sm rounded-md bg-gray-50 dark:bg-gray-900/60 border border-gray-200 dark:border-gray-700/60 text-gray-700 dark:text-gray-300 focus:outline-none transition-colors duration-200"
/>
<span class="text-sm text-gray-500 dark:text-gray-400">days retention</span>
</div>
</SettingsCard>
</div>
{/if}