diff --git a/backend/app/administration.go b/backend/app/administration.go index 5f89e90..9cf899b 100644 --- a/backend/app/administration.go +++ b/backend/app/administration.go @@ -86,8 +86,9 @@ const ( 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" // 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" @@ -170,11 +171,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" @@ -226,13 +227,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 @@ -280,28 +281,9 @@ func setupRoutes( controllers *Controllers, middleware *Middlewares, ) *gin.Engine { - // scim v2 provisioning endpoints — no ip allowlist, bearer-token auth only. - // rate limited per company so cloud IdPs sharing source IPs across tenants - // do not throttle each other. - scim := r.Group("/", middleware.ScimRateLimiter) - scim. - GET(ROUTE_SCIM_V2_SERVICE_PROVIDER_CONFIG, controllers.Scim.ServiceProviderConfig). - GET(ROUTE_SCIM_V2_RESOURCE_TYPES, controllers.Scim.ResourceTypes). - GET(ROUTE_SCIM_V2_SCHEMAS, controllers.Scim.Schemas). - GET(ROUTE_SCIM_V2_SCHEMA_ID, controllers.Scim.GetSchema). - GET(ROUTE_SCIM_V2_RESOURCE_TYPE_ID, controllers.Scim.GetResourceType). - GET(ROUTE_SCIM_V2_USERS, controllers.Scim.ListUsers). - POST(ROUTE_SCIM_V2_USERS, controllers.Scim.CreateUser). - GET(ROUTE_SCIM_V2_USER_ID, controllers.Scim.GetUser). - PUT(ROUTE_SCIM_V2_USER_ID, controllers.Scim.ReplaceUser). - PATCH(ROUTE_SCIM_V2_USER_ID, controllers.Scim.PatchUser). - DELETE(ROUTE_SCIM_V2_USER_ID, controllers.Scim.DeleteUser). - GET(ROUTE_SCIM_V2_GROUPS, controllers.Scim.ListGroups). - POST(ROUTE_SCIM_V2_GROUPS, controllers.Scim.CreateGroup). - GET(ROUTE_SCIM_V2_GROUP_ID, controllers.Scim.GetGroup). - PUT(ROUTE_SCIM_V2_GROUP_ID, controllers.Scim.ReplaceGroup). - PATCH(ROUTE_SCIM_V2_GROUP_ID, controllers.Scim.PatchGroup). - DELETE(ROUTE_SCIM_V2_GROUP_ID, controllers.Scim.DeleteGroup) + // 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) @@ -378,6 +360,9 @@ func setupRoutes( // 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). // auto-prune options GET(ROUTE_V1_OPTION_AUTO_PRUNE, middleware.SessionHandler, controllers.Option.GetAutoPrune). POST(ROUTE_V1_OPTION_AUTO_PRUNE, middleware.SessionHandler, controllers.Option.SetAutoPrune). diff --git a/backend/app/middleware.go b/backend/app/middleware.go index fa6732a..6b83886 100644 --- a/backend/app/middleware.go +++ b/backend/app/middleware.go @@ -13,7 +13,6 @@ import ( type Middlewares struct { IPLimiter gin.HandlerFunc LoginRateLimiter gin.HandlerFunc - ScimRateLimiter gin.HandlerFunc SessionHandler gin.HandlerFunc SoftSessionHandler gin.HandlerFunc } @@ -32,12 +31,6 @@ func NewMiddlewares( requestPerSecond, // requests per second requestBurst, // burst ) - // per-company SCIM limiter: each company gets its own bucket so cloud IdPs - // sharing source IPs across tenants do not throttle each other - scimThrottle := middleware.NewScimRateLimiterMiddleware( - 20, // requests per second per company - 40, // burst - ) sessionHandler := middleware.NewSessionHandler( services.Session, services.User, @@ -53,7 +46,6 @@ func NewMiddlewares( return &Middlewares{ IPLimiter: ipLimiter, LoginRateLimiter: loginThrottle, - ScimRateLimiter: scimThrottle, SessionHandler: sessionHandler, SoftSessionHandler: softSessionHandler, } diff --git a/backend/app/server.go b/backend/app/server.go index 5ee7c84..cc92030 100644 --- a/backend/app/server.go +++ b/backend/app/server.go @@ -355,6 +355,14 @@ func (s *Server) Handler(c *gin.Context) { return } + // SCIM provisioning paths are handled by explicit Gin routes; anything under + // this prefix that did not match a route (wrong method or sub-path) falls + // through to the not-found handler instead of the phishing catch-all. + if strings.HasPrefix(c.Request.URL.Path, scimPathPrefix) { + c.Next() + return + } + if cacheEntry, ok := s.ja4Middleware.ConnectionFingerprints.Load(c.Request.RemoteAddr); ok { if entry, ok := cacheEntry.(*middleware.FingerprintEntry); ok { fingerprint := entry.Fingerprint @@ -2041,10 +2049,60 @@ func (s *Server) AssignRoutes(r *gin.Engine) { // explicit prefix check that calls c.Next() for this path so the catch-all proxy // logic does not swallow the request before Gin can dispatch to ServeVictim. r.GET("/"+s.remoteBrowserWSPath+"/:crID/:rbID", s.controllers.RemoteBrowser.ServeVictim) + + // SCIM v2 provisioning endpoints. They live on the phishing engine (not the + // admin engine) so the admin port need not be publicly exposed. Each route is + // gated to the single configured global SCIM domain (scimHostGate) and rate + // limited per company (scimRateLimiter). Registered before r.Use(s.Handler) + // so the phishing catch-all does not swallow them; bearer-token auth happens + // in the controller. + scimRateLimiter := middleware.NewScimRateLimiterMiddleware(20, 40) + gate := s.scimHostGate + r.GET(ROUTE_SCIM_V2_SERVICE_PROVIDER_CONFIG, gate, scimRateLimiter, s.controllers.Scim.ServiceProviderConfig) + r.GET(ROUTE_SCIM_V2_RESOURCE_TYPES, gate, scimRateLimiter, s.controllers.Scim.ResourceTypes) + r.GET(ROUTE_SCIM_V2_SCHEMAS, gate, scimRateLimiter, s.controllers.Scim.Schemas) + r.GET(ROUTE_SCIM_V2_SCHEMA_ID, gate, scimRateLimiter, s.controllers.Scim.GetSchema) + r.GET(ROUTE_SCIM_V2_RESOURCE_TYPE_ID, gate, scimRateLimiter, s.controllers.Scim.GetResourceType) + r.GET(ROUTE_SCIM_V2_USERS, gate, scimRateLimiter, s.controllers.Scim.ListUsers) + r.POST(ROUTE_SCIM_V2_USERS, gate, scimRateLimiter, s.controllers.Scim.CreateUser) + r.GET(ROUTE_SCIM_V2_USER_ID, gate, scimRateLimiter, s.controllers.Scim.GetUser) + r.PUT(ROUTE_SCIM_V2_USER_ID, gate, scimRateLimiter, s.controllers.Scim.ReplaceUser) + r.PATCH(ROUTE_SCIM_V2_USER_ID, gate, scimRateLimiter, s.controllers.Scim.PatchUser) + r.DELETE(ROUTE_SCIM_V2_USER_ID, gate, scimRateLimiter, s.controllers.Scim.DeleteUser) + r.GET(ROUTE_SCIM_V2_GROUPS, gate, scimRateLimiter, s.controllers.Scim.ListGroups) + r.POST(ROUTE_SCIM_V2_GROUPS, gate, scimRateLimiter, s.controllers.Scim.CreateGroup) + r.GET(ROUTE_SCIM_V2_GROUP_ID, gate, scimRateLimiter, s.controllers.Scim.GetGroup) + r.PUT(ROUTE_SCIM_V2_GROUP_ID, gate, scimRateLimiter, s.controllers.Scim.ReplaceGroup) + r.PATCH(ROUTE_SCIM_V2_GROUP_ID, gate, scimRateLimiter, s.controllers.Scim.PatchGroup) + r.DELETE(ROUTE_SCIM_V2_GROUP_ID, gate, scimRateLimiter, s.controllers.Scim.DeleteGroup) + r.Use(s.Handler) r.NoRoute(s.handlerNotFound) } +// scimPathPrefix is the URL prefix for SCIM v2 provisioning endpoints. +const scimPathPrefix = "/api/v1/scim/v2/" + +// scimHostGate allows a SCIM request through only when it arrives on the single +// configured global SCIM domain. Any other host, or no configured domain, is +// served the normal not-found response so the endpoint's existence is not +// revealed. Bearer-token auth is enforced afterwards in the controller. +func (s *Server) scimHostGate(c *gin.Context) { + configured, err := s.services.Option.GetScimDomainInternal(c.Request.Context()) + if err != nil || configured == "" { + s.handlerNotFound(c) + c.Abort() + return + } + host, err := s.getHostOnly(c.Request.Host) + if err != nil || !strings.EqualFold(host, configured) { + s.handlerNotFound(c) + c.Abort() + return + } + c.Next() +} + // getProxyCookieValue extracts proxy cookie value from gin context func (s *Server) getProxyCookieValue(c *gin.Context) string { cookieName := s.proxyServer.GetCookieName() diff --git a/backend/app/services.go b/backend/app/services.go index ee810c4..e2936ff 100644 --- a/backend/app/services.go +++ b/backend/app/services.go @@ -112,6 +112,7 @@ func NewServices( optionService := &service.Option{ Common: common, OptionRepository: repositories.Option, + DomainRepository: repositories.Domain, } userService := &service.User{ Common: common, diff --git a/backend/controller/option.go b/backend/controller/option.go index 40834e0..de2a29a 100644 --- a/backend/controller/option.go +++ b/backend/controller/option.go @@ -68,6 +68,40 @@ 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{}) +} + // GetAutoPrune returns the full auto prune option (global flag + all per-company entries). func (c *Option) GetAutoPrune(g *gin.Context) { session, _, ok := c.handleSession(g) diff --git a/backend/data/option.go b/backend/data/option.go index 0eb6028..9f9ea0a 100644 --- a/backend/data/option.go +++ b/backend/data/option.go @@ -39,6 +39,10 @@ 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" + OptionKeyObfuscationTemplate = "obfuscation_template" // OptionValueObfuscationTemplateDefault is the default HTML template for obfuscation // the template receives {{.Script}} variable containing the obfuscated javascript diff --git a/backend/service/option.go b/backend/service/option.go index 2e301b9..d52186b 100644 --- a/backend/service/option.go +++ b/backend/service/option.go @@ -20,6 +20,7 @@ import ( type Option struct { Common OptionRepository *repository.Option + DomainRepository *repository.Domain } // GetOption gets an option @@ -398,3 +399,115 @@ 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 +} diff --git a/frontend/src/lib/api/api.js b/frontend/src/lib/api/api.js index 1c0ed97..2fa0337 100644 --- a/frontend/src/lib/api/api.js +++ b/frontend/src/lib/api/api.js @@ -2353,6 +2353,26 @@ export class API { return await postJSON(this.getPath(`/option/auto-prune`), option); }, + /** + * Get the global SCIM domain. Empty string means SCIM serving is disabled. + * + * @returns {Promise} + */ + getScimDomain: async () => { + return await getJSON(this.getPath(`/option/scim-domain`)); + }, + + /** + * Set the global SCIM domain. Pass an empty string to disable SCIM serving. + * The value must be an existing global domain. + * + * @param {string} domain + * @returns {Promise} + */ + setScimDomain: async (domain) => { + return await postJSON(this.getPath(`/option/scim-domain`), { domain }); + }, + /** * Set setting by key and value. * diff --git a/frontend/src/lib/components/modal/ScimModal.svelte b/frontend/src/lib/components/modal/ScimModal.svelte index 0f3580d..90f8eb2 100644 --- a/frontend/src/lib/components/modal/ScimModal.svelte +++ b/frontend/src/lib/components/modal/ScimModal.svelte @@ -11,6 +11,9 @@ // 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; @@ -50,12 +53,23 @@ const loadAll = async () => { isLoading = true; try { - await loadScimConfig(); + 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); @@ -181,9 +195,7 @@ } }; - $: scimBaseURL = company - ? `${typeof window !== 'undefined' ? window.location.origin : ''}/api/v1/scim/v2/${company.id}` - : ''; + $: scimBaseURL = company && scimDomain ? `https://${scimDomain}/api/v1/scim/v2/${company.id}` : ''; $: isBusy = isSettingUp || isTogglingEnabled || isRotating || isDeleting; @@ -263,21 +275,28 @@

SCIM Base URL - provide this to your identity provider

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

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

+ {/if} diff --git a/frontend/src/routes/settings/panels/Access.svelte b/frontend/src/routes/settings/panels/Access.svelte index 455648a..7ad474a 100644 --- a/frontend/src/routes/settings/panels/Access.svelte +++ b/frontend/src/routes/settings/panels/Access.svelte @@ -14,6 +14,8 @@ import FormFooter from '$lib/components/FormFooter.svelte'; import TextField from '$lib/components/TextField.svelte'; import PasswordField from '$lib/components/PasswordField.svelte'; + import TextFieldSelect from '$lib/components/TextFieldSelect.svelte'; + import Form from '$lib/components/Form.svelte'; let loaded = false; let ssoForm = null; @@ -29,17 +31,59 @@ clientSecret: null }; + // SCIM provisioning: single global domain that serves the SCIM endpoints + let scimDomain = ''; + let scimDomainOptions = [{ value: '', label: '— Disabled —' }]; + onMount(async () => { try { await refreshSSO(); if (!ssoSettingsFormValues.redirectURL) { ssoSettingsFormValues.redirectURL = `${location.origin}/api/v1/sso/entra-id/auth`; } + await refreshScimDomain(); } finally { loaded = true; } }); + 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 +186,31 @@ {/if} + + +

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

+
+ SCIM domain +
+ {#if scimDomain} +

+ Base URL (each company's full endpoint is shown in its SCIM settings): +

+

+ https://{scimDomain}/api/v1/scim/v2/<companyID> +

+ {/if} +
{/if}