moved scim endpoints out of admin server

Signed-off-by: Ronni Skansing <rskansing@gmail.com>
This commit is contained in:
Ronni Skansing
2026-06-07 19:56:50 +02:00
parent 1586e3eaec
commit 2a8933f0cd
10 changed files with 357 additions and 62 deletions
+20 -35
View File
@@ -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).
-8
View File
@@ -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,
}
+58
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
@@ -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()
+1
View File
@@ -112,6 +112,7 @@ func NewServices(
optionService := &service.Option{
Common: common,
OptionRepository: repositories.Option,
DomainRepository: repositories.Domain,
}
userService := &service.User{
Common: common,
+34
View File
@@ -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)
+4
View File
@@ -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
+113
View File
@@ -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
}
+20
View File
@@ -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<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 });
},
/**
* Set setting by key and value.
*
@@ -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;
</script>
@@ -263,21 +275,28 @@
<p class="text-xs font-semibold text-gray-500 dark:text-gray-400">
SCIM Base URL - provide this to your identity provider
</p>
<div class="flex items-center gap-2">
<input
type="text"
readonly
value={scimBaseURL}
class="flex-1 px-3 py-2 text-xs rounded-md bg-gray-50 dark:bg-gray-900/60 border border-gray-200 dark:border-gray-700/60 text-gray-700 dark:text-gray-300 font-mono focus:outline-none transition-colors duration-200"
/>
<button
type="button"
on:click={() => copyToClipboard(scimBaseURL)}
class="px-3 py-2 text-sm bg-slate-500 hover:bg-slate-400 dark:bg-gray-700/80 dark:hover:bg-gray-600/80 text-white rounded-md transition-colors duration-200"
>
Copy
</button>
</div>
{#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>
@@ -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}
</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>
{#if scimDomain}
<p class="mt-3 text-xs text-gray-500 dark:text-gray-400 transition-colors duration-200">
Base URL (each company's full endpoint is shown in its SCIM settings):
</p>
<p
class="text-xs text-gray-600 dark:text-gray-300 font-mono break-all transition-colors duration-200"
>
https://{scimDomain}/api/v1/scim/v2/&lt;companyID&gt;
</p>
{/if}
</SettingsCard>
</div>
{/if}