mirror of
https://github.com/phishingclub/phishingclub.git
synced 2026-07-30 07:28:48 +02:00
moved scim endpoints out of admin server
Signed-off-by: Ronni Skansing <rskansing@gmail.com>
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -112,6 +112,7 @@ func NewServices(
|
||||
optionService := &service.Option{
|
||||
Common: common,
|
||||
OptionRepository: repositories.Option,
|
||||
DomainRepository: repositories.Domain,
|
||||
}
|
||||
userService := &service.User{
|
||||
Common: common,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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/<companyID>
|
||||
</p>
|
||||
{/if}
|
||||
</SettingsCard>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user