mirror of
https://github.com/zitadel/zitadel.git
synced 2025-01-10 17:53:40 +00:00
feat: create user scim v2 endpoint (#9132)
# Which Problems Are Solved - Adds infrastructure code (basic implementation, error handling, middlewares, ...) to implement the SCIM v2 interface - Adds support for the user create SCIM v2 endpoint # How the Problems Are Solved - Adds support for the user create SCIM v2 endpoint under `POST /scim/v2/{orgID}/Users` # Additional Context Part of #8140
This commit is contained in:
parent
829f4543da
commit
e621224ab2
@ -590,6 +590,11 @@ SAML:
|
|||||||
# Company: ZITADEL # ZITADEL_SAML_PROVIDERCONFIG_CONTACTPERSON_COMPANY
|
# Company: ZITADEL # ZITADEL_SAML_PROVIDERCONFIG_CONTACTPERSON_COMPANY
|
||||||
# EmailAddress: hi@zitadel.com # ZITADEL_SAML_PROVIDERCONFIG_CONTACTPERSON_EMAILADDRESS
|
# EmailAddress: hi@zitadel.com # ZITADEL_SAML_PROVIDERCONFIG_CONTACTPERSON_EMAILADDRESS
|
||||||
|
|
||||||
|
SCIM:
|
||||||
|
# default values whether an email/phone is considered verified when a users email/phone is created or updated
|
||||||
|
EmailVerified: true # ZITADEL_SCIM_EMAILVERIFIED
|
||||||
|
PhoneVerified: true # ZITADEL_SCIM_PHONEVERIFIED
|
||||||
|
|
||||||
Login:
|
Login:
|
||||||
LanguageCookieName: zitadel.login.lang # ZITADEL_LOGIN_LANGUAGECOOKIENAME
|
LanguageCookieName: zitadel.login.lang # ZITADEL_LOGIN_LANGUAGECOOKIENAME
|
||||||
CSRFCookieName: zitadel.login.csrf # ZITADEL_LOGIN_CSRFCOOKIENAME
|
CSRFCookieName: zitadel.login.csrf # ZITADEL_LOGIN_CSRFCOOKIENAME
|
||||||
|
@ -15,6 +15,7 @@ import (
|
|||||||
"github.com/zitadel/zitadel/internal/api/http/middleware"
|
"github.com/zitadel/zitadel/internal/api/http/middleware"
|
||||||
"github.com/zitadel/zitadel/internal/api/oidc"
|
"github.com/zitadel/zitadel/internal/api/oidc"
|
||||||
"github.com/zitadel/zitadel/internal/api/saml"
|
"github.com/zitadel/zitadel/internal/api/saml"
|
||||||
|
scim_config "github.com/zitadel/zitadel/internal/api/scim/config"
|
||||||
"github.com/zitadel/zitadel/internal/api/ui/console"
|
"github.com/zitadel/zitadel/internal/api/ui/console"
|
||||||
"github.com/zitadel/zitadel/internal/api/ui/login"
|
"github.com/zitadel/zitadel/internal/api/ui/login"
|
||||||
auth_es "github.com/zitadel/zitadel/internal/auth/repository/eventsourcing"
|
auth_es "github.com/zitadel/zitadel/internal/auth/repository/eventsourcing"
|
||||||
@ -60,6 +61,7 @@ type Config struct {
|
|||||||
UserAgentCookie *middleware.UserAgentCookieConfig
|
UserAgentCookie *middleware.UserAgentCookieConfig
|
||||||
OIDC oidc.Config
|
OIDC oidc.Config
|
||||||
SAML saml.Config
|
SAML saml.Config
|
||||||
|
SCIM scim_config.Config
|
||||||
Login login.Config
|
Login login.Config
|
||||||
Console console.Config
|
Console console.Config
|
||||||
AssetStorage static_config.AssetStorageConfig
|
AssetStorage static_config.AssetStorageConfig
|
||||||
|
@ -63,6 +63,8 @@ import (
|
|||||||
"github.com/zitadel/zitadel/internal/api/oidc"
|
"github.com/zitadel/zitadel/internal/api/oidc"
|
||||||
"github.com/zitadel/zitadel/internal/api/robots_txt"
|
"github.com/zitadel/zitadel/internal/api/robots_txt"
|
||||||
"github.com/zitadel/zitadel/internal/api/saml"
|
"github.com/zitadel/zitadel/internal/api/saml"
|
||||||
|
"github.com/zitadel/zitadel/internal/api/scim"
|
||||||
|
"github.com/zitadel/zitadel/internal/api/scim/schemas"
|
||||||
"github.com/zitadel/zitadel/internal/api/ui/console"
|
"github.com/zitadel/zitadel/internal/api/ui/console"
|
||||||
"github.com/zitadel/zitadel/internal/api/ui/console/path"
|
"github.com/zitadel/zitadel/internal/api/ui/console/path"
|
||||||
"github.com/zitadel/zitadel/internal/api/ui/login"
|
"github.com/zitadel/zitadel/internal/api/ui/login"
|
||||||
@ -519,6 +521,17 @@ func startAPIs(
|
|||||||
}
|
}
|
||||||
apis.RegisterHandlerOnPrefix(saml.HandlerPrefix, samlProvider.HttpHandler())
|
apis.RegisterHandlerOnPrefix(saml.HandlerPrefix, samlProvider.HttpHandler())
|
||||||
|
|
||||||
|
apis.RegisterHandlerOnPrefix(
|
||||||
|
schemas.HandlerPrefix,
|
||||||
|
scim.NewServer(
|
||||||
|
commands,
|
||||||
|
queries,
|
||||||
|
verifier,
|
||||||
|
keys.User,
|
||||||
|
&config.SCIM,
|
||||||
|
instanceInterceptor.HandlerFuncWithError,
|
||||||
|
middleware.AuthorizationInterceptor(verifier, config.InternalAuthZ).HandlerFuncWithError))
|
||||||
|
|
||||||
c, err := console.Start(config.Console, config.ExternalSecure, oidcServer.IssuerFromRequest, middleware.CallDurationHandler, instanceInterceptor.Handler, limitingAccessInterceptor, config.CustomerPortal)
|
c, err := console.Start(config.Console, config.ExternalSecure, oidcServer.IssuerFromRequest, middleware.CallDurationHandler, instanceInterceptor.Handler, limitingAccessInterceptor, config.CustomerPortal)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("unable to start console: %w", err)
|
return nil, fmt.Errorf("unable to start console: %w", err)
|
||||||
|
@ -5,6 +5,8 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@ -14,6 +16,7 @@ const (
|
|||||||
CacheControl = "cache-control"
|
CacheControl = "cache-control"
|
||||||
ContentType = "content-type"
|
ContentType = "content-type"
|
||||||
ContentLength = "content-length"
|
ContentLength = "content-length"
|
||||||
|
ContentLocation = "content-location"
|
||||||
Expires = "expires"
|
Expires = "expires"
|
||||||
Location = "location"
|
Location = "location"
|
||||||
Origin = "origin"
|
Origin = "origin"
|
||||||
@ -42,6 +45,9 @@ const (
|
|||||||
PermissionsPolicy = "permissions-policy"
|
PermissionsPolicy = "permissions-policy"
|
||||||
|
|
||||||
ZitadelOrgID = "x-zitadel-orgid"
|
ZitadelOrgID = "x-zitadel-orgid"
|
||||||
|
|
||||||
|
OrgIdInPathVariableName = "orgId"
|
||||||
|
OrgIdInPathVariable = "{" + OrgIdInPathVariableName + "}"
|
||||||
)
|
)
|
||||||
|
|
||||||
type key int
|
type key int
|
||||||
@ -104,6 +110,12 @@ func GetAuthorization(r *http.Request) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func GetOrgID(r *http.Request) string {
|
func GetOrgID(r *http.Request) string {
|
||||||
|
// path variable takes precedence over header
|
||||||
|
orgID, ok := mux.Vars(r)[OrgIdInPathVariableName]
|
||||||
|
if ok {
|
||||||
|
return orgID
|
||||||
|
}
|
||||||
|
|
||||||
return r.Header.Get(ZitadelOrgID)
|
return r.Header.Get(ZitadelOrgID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2,12 +2,15 @@ package middleware
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
|
||||||
"github.com/zitadel/zitadel/internal/api/authz"
|
"github.com/zitadel/zitadel/internal/api/authz"
|
||||||
http_util "github.com/zitadel/zitadel/internal/api/http"
|
http_util "github.com/zitadel/zitadel/internal/api/http"
|
||||||
"github.com/zitadel/zitadel/internal/telemetry/tracing"
|
"github.com/zitadel/zitadel/internal/telemetry/tracing"
|
||||||
|
"github.com/zitadel/zitadel/internal/zerrors"
|
||||||
)
|
)
|
||||||
|
|
||||||
type AuthInterceptor struct {
|
type AuthInterceptor struct {
|
||||||
@ -23,34 +26,40 @@ func AuthorizationInterceptor(verifier authz.APITokenVerifier, authConfig authz.
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *AuthInterceptor) Handler(next http.Handler) http.Handler {
|
func (a *AuthInterceptor) Handler(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return a.HandlerFunc(next)
|
||||||
ctx, err := authorize(r, a.verifier, a.authConfig)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
r = r.WithContext(ctx)
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *AuthInterceptor) HandlerFunc(next http.HandlerFunc) http.HandlerFunc {
|
func (a *AuthInterceptor) HandlerFunc(next http.Handler) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx, err := authorize(r, a.verifier, a.authConfig)
|
ctx, err := authorize(r, a.verifier, a.authConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusUnauthorized)
|
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
r = r.WithContext(ctx)
|
r = r.WithContext(ctx)
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *AuthInterceptor) HandlerFuncWithError(next HandlerFuncWithError) HandlerFuncWithError {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
ctx, err := authorize(r, a.verifier, a.authConfig)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
r = r.WithContext(ctx)
|
||||||
|
return next(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type httpReq struct{}
|
type httpReq struct{}
|
||||||
|
|
||||||
func authorize(r *http.Request, verifier authz.APITokenVerifier, authConfig authz.Config) (_ context.Context, err error) {
|
func authorize(r *http.Request, verifier authz.APITokenVerifier, authConfig authz.Config) (_ context.Context, err error) {
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
authOpt, needsToken := verifier.CheckAuthMethod(r.Method + ":" + r.RequestURI)
|
|
||||||
|
authOpt, needsToken := checkAuthMethod(r, verifier)
|
||||||
if !needsToken {
|
if !needsToken {
|
||||||
return ctx, nil
|
return ctx, nil
|
||||||
}
|
}
|
||||||
@ -59,7 +68,7 @@ func authorize(r *http.Request, verifier authz.APITokenVerifier, authConfig auth
|
|||||||
|
|
||||||
authToken := http_util.GetAuthorization(r)
|
authToken := http_util.GetAuthorization(r)
|
||||||
if authToken == "" {
|
if authToken == "" {
|
||||||
return nil, errors.New("auth header missing")
|
return nil, zerrors.ThrowUnauthenticated(nil, "AUT-1179", "auth header missing")
|
||||||
}
|
}
|
||||||
|
|
||||||
ctxSetter, err := authz.CheckUserAuthorization(authCtx, &httpReq{}, authToken, http_util.GetOrgID(r), "", verifier, authConfig, authOpt, r.RequestURI)
|
ctxSetter, err := authz.CheckUserAuthorization(authCtx, &httpReq{}, authToken, http_util.GetOrgID(r), "", verifier, authConfig, authOpt, r.RequestURI)
|
||||||
@ -69,3 +78,30 @@ func authorize(r *http.Request, verifier authz.APITokenVerifier, authConfig auth
|
|||||||
span.End()
|
span.End()
|
||||||
return ctxSetter(ctx), nil
|
return ctxSetter(ctx), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func checkAuthMethod(r *http.Request, verifier authz.APITokenVerifier) (authz.Option, bool) {
|
||||||
|
authOpt, needsToken := verifier.CheckAuthMethod(r.Method + ":" + r.RequestURI)
|
||||||
|
if needsToken {
|
||||||
|
return authOpt, true
|
||||||
|
}
|
||||||
|
|
||||||
|
route := mux.CurrentRoute(r)
|
||||||
|
if route == nil {
|
||||||
|
return authOpt, false
|
||||||
|
}
|
||||||
|
|
||||||
|
pathTemplate, err := route.GetPathTemplate()
|
||||||
|
if err != nil || pathTemplate == "" {
|
||||||
|
return authOpt, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// the path prefix is usually handled in a router in upper layer
|
||||||
|
// trim the query and the path of the url to get the correct path prefix
|
||||||
|
pathPrefix := r.RequestURI
|
||||||
|
if i := strings.Index(pathPrefix, "?"); i != -1 {
|
||||||
|
pathPrefix = pathPrefix[0:i]
|
||||||
|
}
|
||||||
|
pathPrefix = strings.TrimSuffix(pathPrefix, r.URL.Path)
|
||||||
|
|
||||||
|
return verifier.CheckAuthMethod(r.Method + ":" + pathPrefix + pathTemplate)
|
||||||
|
}
|
||||||
|
26
internal/api/http/middleware/handler.go
Normal file
26
internal/api/http/middleware/handler.go
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import "net/http"
|
||||||
|
|
||||||
|
// HandlerFuncWithError is a http handler func which can return an error
|
||||||
|
// the error should then get handled later on in the pipeline by an error handler
|
||||||
|
// the error handler can be dependent on the interface standard (e.g. SCIM, Problem Details, ...)
|
||||||
|
type HandlerFuncWithError = func(w http.ResponseWriter, r *http.Request) error
|
||||||
|
|
||||||
|
// MiddlewareWithErrorFunc is a http middleware which can return an error
|
||||||
|
// the error should then get handled later on in the pipeline by an error handler
|
||||||
|
// the error handler can be dependent on the interface standard (e.g. SCIM, Problem Details, ...)
|
||||||
|
type MiddlewareWithErrorFunc = func(HandlerFuncWithError) HandlerFuncWithError
|
||||||
|
|
||||||
|
// ErrorHandlerFunc handles errors and returns a regular http handler
|
||||||
|
type ErrorHandlerFunc = func(HandlerFuncWithError) http.Handler
|
||||||
|
|
||||||
|
func ChainedWithErrorHandler(errorHandler ErrorHandlerFunc, middlewares ...MiddlewareWithErrorFunc) func(HandlerFuncWithError) http.Handler {
|
||||||
|
return func(next HandlerFuncWithError) http.Handler {
|
||||||
|
for i := len(middlewares) - 1; i >= 0; i-- {
|
||||||
|
next = middlewares[i](next)
|
||||||
|
}
|
||||||
|
|
||||||
|
return errorHandler(next)
|
||||||
|
}
|
||||||
|
}
|
@ -34,43 +34,57 @@ func InstanceInterceptor(verifier authz.InstanceVerifier, externalDomain string,
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *instanceInterceptor) Handler(next http.Handler) http.Handler {
|
func (a *instanceInterceptor) Handler(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return a.HandlerFunc(next)
|
||||||
a.handleInstance(w, r, next)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *instanceInterceptor) HandlerFunc(next http.HandlerFunc) http.HandlerFunc {
|
func (a *instanceInterceptor) HandlerFunc(next http.Handler) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
a.handleInstance(w, r, next)
|
ctx, err := a.setInstanceIfNeeded(r.Context(), r)
|
||||||
}
|
if err == nil {
|
||||||
}
|
r = r.WithContext(ctx)
|
||||||
|
|
||||||
func (a *instanceInterceptor) handleInstance(w http.ResponseWriter, r *http.Request, next http.Handler) {
|
|
||||||
for _, prefix := range a.ignoredPrefixes {
|
|
||||||
if strings.HasPrefix(r.URL.Path, prefix) {
|
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
|
||||||
ctx, err := setInstance(r, a.verifier)
|
|
||||||
if err != nil {
|
|
||||||
origin := zitadel_http.DomainContext(r.Context())
|
origin := zitadel_http.DomainContext(r.Context())
|
||||||
logging.WithFields("origin", origin.Origin(), "externalDomain", a.externalDomain).WithError(err).Error("unable to set instance")
|
logging.WithFields("origin", origin.Origin(), "externalDomain", a.externalDomain).WithError(err).Error("unable to set instance")
|
||||||
|
|
||||||
zErr := new(zerrors.ZitadelError)
|
zErr := new(zerrors.ZitadelError)
|
||||||
if errors.As(err, &zErr) {
|
if errors.As(err, &zErr) {
|
||||||
zErr.SetMessage(a.translator.LocalizeFromRequest(r, zErr.GetMessage(), nil))
|
zErr.SetMessage(a.translator.LocalizeFromRequest(r, zErr.GetMessage(), nil))
|
||||||
http.Error(w, fmt.Sprintf("unable to set instance using origin %s (ExternalDomain is %s): %s", origin, a.externalDomain, zErr), http.StatusNotFound)
|
http.Error(w, fmt.Sprintf("unable to set instance using origin %s (ExternalDomain is %s): %s", origin, a.externalDomain, zErr), http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
http.Error(w, fmt.Sprintf("unable to set instance using origin %s (ExternalDomain is %s)", origin, a.externalDomain), http.StatusNotFound)
|
http.Error(w, fmt.Sprintf("unable to set instance using origin %s (ExternalDomain is %s)", origin, a.externalDomain), http.StatusNotFound)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
r = r.WithContext(ctx)
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func setInstance(r *http.Request, verifier authz.InstanceVerifier) (_ context.Context, err error) {
|
func (a *instanceInterceptor) HandlerFuncWithError(next HandlerFuncWithError) HandlerFuncWithError {
|
||||||
ctx := r.Context()
|
return func(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
ctx, err := a.setInstanceIfNeeded(r.Context(), r)
|
||||||
|
if err != nil {
|
||||||
|
origin := zitadel_http.DomainContext(r.Context())
|
||||||
|
logging.WithFields("origin", origin.Origin(), "externalDomain", a.externalDomain).WithError(err).Error("unable to set instance")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
r = r.WithContext(ctx)
|
||||||
|
return next(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *instanceInterceptor) setInstanceIfNeeded(ctx context.Context, r *http.Request) (context.Context, error) {
|
||||||
|
for _, prefix := range a.ignoredPrefixes {
|
||||||
|
if strings.HasPrefix(r.URL.Path, prefix) {
|
||||||
|
return ctx, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return setInstance(ctx, a.verifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setInstance(ctx context.Context, verifier authz.InstanceVerifier) (_ context.Context, err error) {
|
||||||
authCtx, span := tracing.NewServerInterceptorSpan(ctx)
|
authCtx, span := tracing.NewServerInterceptorSpan(ctx)
|
||||||
defer func() { span.EndWithError(err) }()
|
defer func() { span.EndWithError(err) }()
|
||||||
|
|
||||||
|
@ -72,7 +72,7 @@ func Test_instanceInterceptor_Handler(t *testing.T) {
|
|||||||
translator: newZitadelTranslator(),
|
translator: newZitadelTranslator(),
|
||||||
}
|
}
|
||||||
next := &testHandler{}
|
next := &testHandler{}
|
||||||
got := a.HandlerFunc(next.ServeHTTP)
|
got := a.HandlerFunc(next)
|
||||||
rr := httptest.NewRecorder()
|
rr := httptest.NewRecorder()
|
||||||
got.ServeHTTP(rr, tt.args.request)
|
got.ServeHTTP(rr, tt.args.request)
|
||||||
assert.Equal(t, tt.res.statusCode, rr.Code)
|
assert.Equal(t, tt.res.statusCode, rr.Code)
|
||||||
@ -136,7 +136,7 @@ func Test_instanceInterceptor_HandlerFunc(t *testing.T) {
|
|||||||
translator: newZitadelTranslator(),
|
translator: newZitadelTranslator(),
|
||||||
}
|
}
|
||||||
next := &testHandler{}
|
next := &testHandler{}
|
||||||
got := a.HandlerFunc(next.ServeHTTP)
|
got := a.HandlerFunc(next)
|
||||||
rr := httptest.NewRecorder()
|
rr := httptest.NewRecorder()
|
||||||
got.ServeHTTP(rr, tt.args.request)
|
got.ServeHTTP(rr, tt.args.request)
|
||||||
assert.Equal(t, tt.res.statusCode, rr.Code)
|
assert.Equal(t, tt.res.statusCode, rr.Code)
|
||||||
@ -145,9 +145,78 @@ func Test_instanceInterceptor_HandlerFunc(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func Test_instanceInterceptor_HandlerFuncWithError(t *testing.T) {
|
||||||
|
type fields struct {
|
||||||
|
verifier authz.InstanceVerifier
|
||||||
|
}
|
||||||
|
type args struct {
|
||||||
|
request *http.Request
|
||||||
|
}
|
||||||
|
type res struct {
|
||||||
|
wantErr bool
|
||||||
|
context context.Context
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
fields fields
|
||||||
|
args args
|
||||||
|
res res
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
"setInstance error",
|
||||||
|
fields{
|
||||||
|
verifier: &mockInstanceVerifier{},
|
||||||
|
},
|
||||||
|
args{
|
||||||
|
request: httptest.NewRequest("", "/url", nil),
|
||||||
|
},
|
||||||
|
res{
|
||||||
|
wantErr: true,
|
||||||
|
context: nil,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"setInstance ok",
|
||||||
|
fields{
|
||||||
|
verifier: &mockInstanceVerifier{instanceHost: "host"},
|
||||||
|
},
|
||||||
|
args{
|
||||||
|
request: func() *http.Request {
|
||||||
|
r := httptest.NewRequest("", "/url", nil)
|
||||||
|
r = r.WithContext(zitadel_http.WithDomainContext(r.Context(), &zitadel_http.DomainCtx{InstanceHost: "host"}))
|
||||||
|
return r
|
||||||
|
}(),
|
||||||
|
},
|
||||||
|
res{
|
||||||
|
context: authz.WithInstance(zitadel_http.WithDomainContext(context.Background(), &zitadel_http.DomainCtx{InstanceHost: "host"}), &mockInstance{}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
a := &instanceInterceptor{
|
||||||
|
verifier: tt.fields.verifier,
|
||||||
|
translator: newZitadelTranslator(),
|
||||||
|
}
|
||||||
|
var ctx context.Context
|
||||||
|
got := a.HandlerFuncWithError(func(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
ctx = r.Context()
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
err := got(rr, tt.args.request)
|
||||||
|
if (err != nil) != tt.res.wantErr {
|
||||||
|
t.Errorf("got error %v, want %v", err, tt.res.wantErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, tt.res.context, ctx)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func Test_setInstance(t *testing.T) {
|
func Test_setInstance(t *testing.T) {
|
||||||
type args struct {
|
type args struct {
|
||||||
r *http.Request
|
ctx context.Context
|
||||||
verifier authz.InstanceVerifier
|
verifier authz.InstanceVerifier
|
||||||
}
|
}
|
||||||
type res struct {
|
type res struct {
|
||||||
@ -162,10 +231,7 @@ func Test_setInstance(t *testing.T) {
|
|||||||
{
|
{
|
||||||
"no domain context, not found error",
|
"no domain context, not found error",
|
||||||
args{
|
args{
|
||||||
r: func() *http.Request {
|
ctx: context.Background(),
|
||||||
r := httptest.NewRequest("", "/url", nil)
|
|
||||||
return r
|
|
||||||
}(),
|
|
||||||
verifier: &mockInstanceVerifier{},
|
verifier: &mockInstanceVerifier{},
|
||||||
},
|
},
|
||||||
res{
|
res{
|
||||||
@ -176,10 +242,7 @@ func Test_setInstance(t *testing.T) {
|
|||||||
{
|
{
|
||||||
"instanceHost found, ok",
|
"instanceHost found, ok",
|
||||||
args{
|
args{
|
||||||
r: func() *http.Request {
|
ctx: zitadel_http.WithDomainContext(context.Background(), &zitadel_http.DomainCtx{InstanceHost: "host", Protocol: "https"}),
|
||||||
r := httptest.NewRequest("", "/url", nil)
|
|
||||||
return r.WithContext(zitadel_http.WithDomainContext(r.Context(), &zitadel_http.DomainCtx{InstanceHost: "host", Protocol: "https"}))
|
|
||||||
}(),
|
|
||||||
verifier: &mockInstanceVerifier{instanceHost: "host"},
|
verifier: &mockInstanceVerifier{instanceHost: "host"},
|
||||||
},
|
},
|
||||||
res{
|
res{
|
||||||
@ -190,10 +253,7 @@ func Test_setInstance(t *testing.T) {
|
|||||||
{
|
{
|
||||||
"instanceHost not found, error",
|
"instanceHost not found, error",
|
||||||
args{
|
args{
|
||||||
r: func() *http.Request {
|
ctx: zitadel_http.WithDomainContext(context.Background(), &zitadel_http.DomainCtx{InstanceHost: "fromorigin:9999", Protocol: "https"}),
|
||||||
r := httptest.NewRequest("", "/url", nil)
|
|
||||||
return r.WithContext(zitadel_http.WithDomainContext(r.Context(), &zitadel_http.DomainCtx{InstanceHost: "fromorigin:9999", Protocol: "https"}))
|
|
||||||
}(),
|
|
||||||
verifier: &mockInstanceVerifier{instanceHost: "unknowndomain"},
|
verifier: &mockInstanceVerifier{instanceHost: "unknowndomain"},
|
||||||
},
|
},
|
||||||
res{
|
res{
|
||||||
@ -204,7 +264,7 @@ func Test_setInstance(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
got, err := setInstance(tt.args.r, tt.args.verifier)
|
got, err := setInstance(tt.args.ctx, tt.args.verifier)
|
||||||
if (err != nil) != tt.res.err {
|
if (err != nil) != tt.res.err {
|
||||||
t.Errorf("setInstance() error = %v, wantErr %v", err, tt.res.err)
|
t.Errorf("setInstance() error = %v, wantErr %v", err, tt.res.err)
|
||||||
return
|
return
|
||||||
|
13
internal/api/scim/authz.go
Normal file
13
internal/api/scim/authz.go
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
package scim
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/zitadel/zitadel/internal/api/authz"
|
||||||
|
"github.com/zitadel/zitadel/internal/api/http"
|
||||||
|
"github.com/zitadel/zitadel/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
var AuthMapping = authz.MethodMapping{
|
||||||
|
"POST:/scim/v2/" + http.OrgIdInPathVariable + "/Users": {
|
||||||
|
Permission: domain.PermissionUserWrite,
|
||||||
|
},
|
||||||
|
}
|
6
internal/api/scim/config/config.go
Normal file
6
internal/api/scim/config/config.go
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
EmailVerified bool
|
||||||
|
PhoneVerified bool
|
||||||
|
}
|
28
internal/api/scim/integration_test/scim_test.go
Normal file
28
internal/api/scim/integration_test/scim_test.go
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package integration_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"github.com/zitadel/zitadel/internal/integration"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
Instance *integration.Instance
|
||||||
|
CTX context.Context
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMain(m *testing.M) {
|
||||||
|
os.Exit(func() int {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
Instance = integration.NewInstance(ctx)
|
||||||
|
|
||||||
|
CTX = Instance.WithAuthorization(ctx, integration.UserTypeOrgOwner)
|
||||||
|
return m.Run()
|
||||||
|
}())
|
||||||
|
}
|
116
internal/api/scim/integration_test/testdata/users_create_test_full.json
vendored
Normal file
116
internal/api/scim/integration_test/testdata/users_create_test_full.json
vendored
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
{
|
||||||
|
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
|
||||||
|
"externalId": "701984",
|
||||||
|
"userName": "bjensen@example.com",
|
||||||
|
"name": {
|
||||||
|
"formatted": "Ms. Barbara J Jensen, III",
|
||||||
|
"familyName": "Jensen",
|
||||||
|
"givenName": "Barbara",
|
||||||
|
"middleName": "Jane",
|
||||||
|
"honorificPrefix": "Ms.",
|
||||||
|
"honorificSuffix": "III"
|
||||||
|
},
|
||||||
|
"displayName": "Babs Jensen",
|
||||||
|
"nickName": "Babs",
|
||||||
|
"profileUrl": "http://login.example.com/bjensen",
|
||||||
|
"emails": [
|
||||||
|
{
|
||||||
|
"value": "bjensen@example.com",
|
||||||
|
"type": "work",
|
||||||
|
"primary": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "babs@jensen.org",
|
||||||
|
"type": "home"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"addresses": [
|
||||||
|
{
|
||||||
|
"type": "work",
|
||||||
|
"streetAddress": "100 Universal City Plaza",
|
||||||
|
"locality": "Hollywood",
|
||||||
|
"region": "CA",
|
||||||
|
"postalCode": "91608",
|
||||||
|
"country": "USA",
|
||||||
|
"formatted": "100 Universal City Plaza\nHollywood, CA 91608 USA",
|
||||||
|
"primary": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "home",
|
||||||
|
"streetAddress": "456 Hollywood Blvd",
|
||||||
|
"locality": "Hollywood",
|
||||||
|
"region": "CA",
|
||||||
|
"postalCode": "91608",
|
||||||
|
"country": "USA",
|
||||||
|
"formatted": "456 Hollywood Blvd\nHollywood, CA 91608 USA"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"phoneNumbers": [
|
||||||
|
{
|
||||||
|
"value": "555-555-5555",
|
||||||
|
"type": "work",
|
||||||
|
"primary": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "555-555-4444",
|
||||||
|
"type": "mobile"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ims": [
|
||||||
|
{
|
||||||
|
"value": "someaimhandle",
|
||||||
|
"type": "aim"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "twitterhandle",
|
||||||
|
"type": "X"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"photos": [
|
||||||
|
{
|
||||||
|
"value":
|
||||||
|
"https://photos.example.com/profilephoto/72930000000Ccne/F",
|
||||||
|
"type": "photo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value":
|
||||||
|
"https://photos.example.com/profilephoto/72930000000Ccne/T",
|
||||||
|
"type": "thumbnail"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"roles": [
|
||||||
|
{
|
||||||
|
"value": "my-role-1",
|
||||||
|
"display": "Rolle 1",
|
||||||
|
"type": "main-role",
|
||||||
|
"primary": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "my-role-2",
|
||||||
|
"display": "Rolle 2",
|
||||||
|
"type": "secondary-role",
|
||||||
|
"primary": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"entitlements": [
|
||||||
|
{
|
||||||
|
"value": "my-entitlement-1",
|
||||||
|
"display": "Entitlement 1",
|
||||||
|
"type": "main-entitlement",
|
||||||
|
"primary": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "my-entitlement-2",
|
||||||
|
"display": "Entitlement 2",
|
||||||
|
"type": "secondary-entitlement",
|
||||||
|
"primary": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"userType": "Employee",
|
||||||
|
"title": "Tour Guide",
|
||||||
|
"preferredLanguage": "en-US",
|
||||||
|
"locale": "en-US",
|
||||||
|
"timezone": "America/Los_Angeles",
|
||||||
|
"active":true,
|
||||||
|
"password": "Password1!"
|
||||||
|
}
|
17
internal/api/scim/integration_test/testdata/users_create_test_invalid_locale.json
vendored
Normal file
17
internal/api/scim/integration_test/testdata/users_create_test_invalid_locale.json
vendored
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"schemas": [
|
||||||
|
"urn:ietf:params:scim:schemas:core:2.0:User"
|
||||||
|
],
|
||||||
|
"userName": "acmeUser1",
|
||||||
|
"name": {
|
||||||
|
"familyName": "Ross",
|
||||||
|
"givenName": "Bethany"
|
||||||
|
},
|
||||||
|
"emails": [
|
||||||
|
{
|
||||||
|
"value": "user1@example.com",
|
||||||
|
"primary": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"locale": "fooBar"
|
||||||
|
}
|
17
internal/api/scim/integration_test/testdata/users_create_test_invalid_password.json
vendored
Normal file
17
internal/api/scim/integration_test/testdata/users_create_test_invalid_password.json
vendored
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"schemas": [
|
||||||
|
"urn:ietf:params:scim:schemas:core:2.0:User"
|
||||||
|
],
|
||||||
|
"userName": "acmeUser1",
|
||||||
|
"name": {
|
||||||
|
"familyName": "Ross",
|
||||||
|
"givenName": "Bethany"
|
||||||
|
},
|
||||||
|
"emails": [
|
||||||
|
{
|
||||||
|
"value": "user1@example.com",
|
||||||
|
"primary": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"password": "fooBar"
|
||||||
|
}
|
17
internal/api/scim/integration_test/testdata/users_create_test_invalid_profile_url.json
vendored
Normal file
17
internal/api/scim/integration_test/testdata/users_create_test_invalid_profile_url.json
vendored
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"schemas": [
|
||||||
|
"urn:ietf:params:scim:schemas:core:2.0:User"
|
||||||
|
],
|
||||||
|
"userName": "acmeUser1",
|
||||||
|
"name": {
|
||||||
|
"familyName": "Ross",
|
||||||
|
"givenName": "Bethany"
|
||||||
|
},
|
||||||
|
"emails": [
|
||||||
|
{
|
||||||
|
"value": "user1@example.com",
|
||||||
|
"primary": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"profileUrl": "ftp://login.example.com/bjensen"
|
||||||
|
}
|
17
internal/api/scim/integration_test/testdata/users_create_test_invalid_timezone.json
vendored
Normal file
17
internal/api/scim/integration_test/testdata/users_create_test_invalid_timezone.json
vendored
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"schemas": [
|
||||||
|
"urn:ietf:params:scim:schemas:core:2.0:User"
|
||||||
|
],
|
||||||
|
"userName": "acmeUser1",
|
||||||
|
"name": {
|
||||||
|
"familyName": "Ross",
|
||||||
|
"givenName": "Bethany"
|
||||||
|
},
|
||||||
|
"emails": [
|
||||||
|
{
|
||||||
|
"value": "user1@example.com",
|
||||||
|
"primary": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"timezone": "fooBar"
|
||||||
|
}
|
16
internal/api/scim/integration_test/testdata/users_create_test_minimal.json
vendored
Normal file
16
internal/api/scim/integration_test/testdata/users_create_test_minimal.json
vendored
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"schemas": [
|
||||||
|
"urn:ietf:params:scim:schemas:core:2.0:User"
|
||||||
|
],
|
||||||
|
"userName": "acmeUser1",
|
||||||
|
"name": {
|
||||||
|
"familyName": "Ross",
|
||||||
|
"givenName": "Bethany"
|
||||||
|
},
|
||||||
|
"emails": [
|
||||||
|
{
|
||||||
|
"value": "user1@example.com",
|
||||||
|
"primary": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
10
internal/api/scim/integration_test/testdata/users_create_test_missing_email.json
vendored
Normal file
10
internal/api/scim/integration_test/testdata/users_create_test_missing_email.json
vendored
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"schemas": [
|
||||||
|
"urn:ietf:params:scim:schemas:core:2.0:User"
|
||||||
|
],
|
||||||
|
"userName": "acmeUser1",
|
||||||
|
"name": {
|
||||||
|
"familyName": "Ross",
|
||||||
|
"givenName": "Bethany"
|
||||||
|
}
|
||||||
|
}
|
15
internal/api/scim/integration_test/testdata/users_create_test_missing_name.json
vendored
Normal file
15
internal/api/scim/integration_test/testdata/users_create_test_missing_name.json
vendored
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"schemas": [
|
||||||
|
"urn:ietf:params:scim:schemas:core:2.0:User"
|
||||||
|
],
|
||||||
|
"userName": "acmeUser1",
|
||||||
|
"name": {
|
||||||
|
"givenName": "Bethany"
|
||||||
|
},
|
||||||
|
"emails": [
|
||||||
|
{
|
||||||
|
"value": "user1@example.com",
|
||||||
|
"primary": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
15
internal/api/scim/integration_test/testdata/users_create_test_missing_username.json
vendored
Normal file
15
internal/api/scim/integration_test/testdata/users_create_test_missing_username.json
vendored
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"schemas": [
|
||||||
|
"urn:ietf:params:scim:schemas:core:2.0:User"
|
||||||
|
],
|
||||||
|
"name": {
|
||||||
|
"familyName": "Ross",
|
||||||
|
"givenName": "Bethany"
|
||||||
|
},
|
||||||
|
"emails": [
|
||||||
|
{
|
||||||
|
"value": "user1@example.com",
|
||||||
|
"primary": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
250
internal/api/scim/integration_test/users_create_test.go
Normal file
250
internal/api/scim/integration_test/users_create_test.go
Normal file
@ -0,0 +1,250 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package integration_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
_ "embed"
|
||||||
|
"errors"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/zitadel/zitadel/internal/api/scim/schemas"
|
||||||
|
"github.com/zitadel/zitadel/internal/integration"
|
||||||
|
"github.com/zitadel/zitadel/internal/integration/scim"
|
||||||
|
"github.com/zitadel/zitadel/pkg/grpc/management"
|
||||||
|
"github.com/zitadel/zitadel/pkg/grpc/user/v2"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"net/http"
|
||||||
|
"path"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
//go:embed testdata/users_create_test_minimal.json
|
||||||
|
minimalUserJson []byte
|
||||||
|
|
||||||
|
//go:embed testdata/users_create_test_full.json
|
||||||
|
fullUserJson []byte
|
||||||
|
|
||||||
|
//go:embed testdata/users_create_test_missing_username.json
|
||||||
|
missingUserNameUserJson []byte
|
||||||
|
|
||||||
|
//go:embed testdata/users_create_test_missing_name.json
|
||||||
|
missingNameUserJson []byte
|
||||||
|
|
||||||
|
//go:embed testdata/users_create_test_missing_email.json
|
||||||
|
missingEmailUserJson []byte
|
||||||
|
|
||||||
|
//go:embed testdata/users_create_test_invalid_password.json
|
||||||
|
invalidPasswordUserJson []byte
|
||||||
|
|
||||||
|
//go:embed testdata/users_create_test_invalid_profile_url.json
|
||||||
|
invalidProfileUrlUserJson []byte
|
||||||
|
|
||||||
|
//go:embed testdata/users_create_test_invalid_locale.json
|
||||||
|
invalidLocaleUserJson []byte
|
||||||
|
|
||||||
|
//go:embed testdata/users_create_test_invalid_timezone.json
|
||||||
|
invalidTimeZoneUserJson []byte
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCreateUser(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
body []byte
|
||||||
|
ctx context.Context
|
||||||
|
wantErr bool
|
||||||
|
scimErrorType string
|
||||||
|
errorStatus int
|
||||||
|
zitadelErrID string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "minimal user",
|
||||||
|
body: minimalUserJson,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "full user",
|
||||||
|
body: fullUserJson,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing userName",
|
||||||
|
wantErr: true,
|
||||||
|
scimErrorType: "invalidValue",
|
||||||
|
body: missingUserNameUserJson,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// this is an expected schema violation
|
||||||
|
name: "missing name",
|
||||||
|
wantErr: true,
|
||||||
|
scimErrorType: "invalidValue",
|
||||||
|
body: missingNameUserJson,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing email",
|
||||||
|
wantErr: true,
|
||||||
|
scimErrorType: "invalidValue",
|
||||||
|
body: missingEmailUserJson,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "password complexity violation",
|
||||||
|
wantErr: true,
|
||||||
|
scimErrorType: "invalidValue",
|
||||||
|
body: invalidPasswordUserJson,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid profile url",
|
||||||
|
wantErr: true,
|
||||||
|
scimErrorType: "invalidValue",
|
||||||
|
zitadelErrID: "SCIM-htturl1",
|
||||||
|
body: invalidProfileUrlUserJson,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid time zone",
|
||||||
|
wantErr: true,
|
||||||
|
scimErrorType: "invalidValue",
|
||||||
|
body: invalidTimeZoneUserJson,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid locale",
|
||||||
|
wantErr: true,
|
||||||
|
scimErrorType: "invalidValue",
|
||||||
|
body: invalidLocaleUserJson,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "not authenticated",
|
||||||
|
body: minimalUserJson,
|
||||||
|
ctx: context.Background(),
|
||||||
|
wantErr: true,
|
||||||
|
errorStatus: http.StatusUnauthorized,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no permissions",
|
||||||
|
body: minimalUserJson,
|
||||||
|
ctx: Instance.WithAuthorization(CTX, integration.UserTypeNoPermission),
|
||||||
|
wantErr: true,
|
||||||
|
errorStatus: http.StatusNotFound,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
ctx := tt.ctx
|
||||||
|
if ctx == nil {
|
||||||
|
ctx = CTX
|
||||||
|
}
|
||||||
|
|
||||||
|
createdUser, err := Instance.Client.SCIM.Users.Create(ctx, Instance.DefaultOrg.Id, tt.body)
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Errorf("CreateUser() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
assert.IsType(t, new(scim.ScimError), err)
|
||||||
|
|
||||||
|
var scimErr *scim.ScimError
|
||||||
|
errors.As(err, &scimErr)
|
||||||
|
assert.Equal(t, tt.scimErrorType, scimErr.ScimType)
|
||||||
|
|
||||||
|
statusCode := tt.errorStatus
|
||||||
|
if statusCode == 0 {
|
||||||
|
statusCode = http.StatusBadRequest
|
||||||
|
}
|
||||||
|
assert.Equal(t, strconv.Itoa(statusCode), scimErr.Status)
|
||||||
|
|
||||||
|
if tt.zitadelErrID != "" {
|
||||||
|
assert.Equal(t, tt.zitadelErrID, scimErr.ZitadelDetail.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.NotEmpty(t, createdUser.ID)
|
||||||
|
assert.EqualValues(t, []schemas.ScimSchemaType{"urn:ietf:params:scim:schemas:core:2.0:User"}, createdUser.Resource.Schemas)
|
||||||
|
assert.Equal(t, schemas.ScimResourceTypeSingular("User"), createdUser.Resource.Meta.ResourceType)
|
||||||
|
assert.Equal(t, "http://"+Instance.Host()+path.Join(schemas.HandlerPrefix, Instance.DefaultOrg.Id, "Users", createdUser.ID), createdUser.Resource.Meta.Location)
|
||||||
|
assert.Nil(t, createdUser.Password)
|
||||||
|
|
||||||
|
_, err = Instance.Client.UserV2.DeleteUser(CTX, &user.DeleteUserRequest{UserId: createdUser.ID})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateUser_duplicate(t *testing.T) {
|
||||||
|
createdUser, err := Instance.Client.SCIM.Users.Create(CTX, Instance.DefaultOrg.Id, minimalUserJson)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = Instance.Client.SCIM.Users.Create(CTX, Instance.DefaultOrg.Id, minimalUserJson)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.IsType(t, new(scim.ScimError), err)
|
||||||
|
|
||||||
|
var scimErr *scim.ScimError
|
||||||
|
errors.As(err, &scimErr)
|
||||||
|
assert.Equal(t, strconv.Itoa(http.StatusConflict), scimErr.Status)
|
||||||
|
assert.Equal(t, "User already exists", scimErr.Detail)
|
||||||
|
|
||||||
|
_, err = Instance.Client.UserV2.DeleteUser(CTX, &user.DeleteUserRequest{UserId: createdUser.ID})
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateUser_metadata(t *testing.T) {
|
||||||
|
createdUser, err := Instance.Client.SCIM.Users.Create(CTX, Instance.DefaultOrg.Id, fullUserJson)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
md, err := Instance.Client.Mgmt.ListUserMetadata(CTX, &management.ListUserMetadataRequest{
|
||||||
|
Id: createdUser.ID,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
mdMap := make(map[string]string)
|
||||||
|
for i := range md.Result {
|
||||||
|
mdMap[md.Result[i].Key] = string(md.Result[i].Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
integration.AssertMapContains(t, mdMap, "urn:zitadel:scim:name.honorificPrefix", "Ms.")
|
||||||
|
integration.AssertMapContains(t, mdMap, "urn:zitadel:scim:timezone", "America/Los_Angeles")
|
||||||
|
integration.AssertMapContains(t, mdMap, "urn:zitadel:scim:photos", `[{"value":"https://photos.example.com/profilephoto/72930000000Ccne/F","type":"photo"},{"value":"https://photos.example.com/profilephoto/72930000000Ccne/T","type":"thumbnail"}]`)
|
||||||
|
integration.AssertMapContains(t, mdMap, "urn:zitadel:scim:addresses", `[{"type":"work","streetAddress":"100 Universal City Plaza","locality":"Hollywood","region":"CA","postalCode":"91608","country":"USA","formatted":"100 Universal City Plaza\nHollywood, CA 91608 USA","primary":true},{"type":"home","streetAddress":"456 Hollywood Blvd","locality":"Hollywood","region":"CA","postalCode":"91608","country":"USA","formatted":"456 Hollywood Blvd\nHollywood, CA 91608 USA"}]`)
|
||||||
|
integration.AssertMapContains(t, mdMap, "urn:zitadel:scim:entitlements", `[{"value":"my-entitlement-1","display":"Entitlement 1","type":"main-entitlement","primary":true},{"value":"my-entitlement-2","display":"Entitlement 2","type":"secondary-entitlement"}]`)
|
||||||
|
integration.AssertMapContains(t, mdMap, "urn:zitadel:scim:externalId", "701984")
|
||||||
|
integration.AssertMapContains(t, mdMap, "urn:zitadel:scim:name.middleName", "Jane")
|
||||||
|
integration.AssertMapContains(t, mdMap, "urn:zitadel:scim:name.honorificSuffix", "III")
|
||||||
|
integration.AssertMapContains(t, mdMap, "urn:zitadel:scim:profileURL", "http://login.example.com/bjensen")
|
||||||
|
integration.AssertMapContains(t, mdMap, "urn:zitadel:scim:title", "Tour Guide")
|
||||||
|
integration.AssertMapContains(t, mdMap, "urn:zitadel:scim:locale", "en-US")
|
||||||
|
integration.AssertMapContains(t, mdMap, "urn:zitadel:scim:ims", `[{"value":"someaimhandle","type":"aim"},{"value":"twitterhandle","type":"X"}]`)
|
||||||
|
integration.AssertMapContains(t, mdMap, "urn:zitadel:scim:roles", `[{"value":"my-role-1","display":"Rolle 1","type":"main-role","primary":true},{"value":"my-role-2","display":"Rolle 2","type":"secondary-role"}]`)
|
||||||
|
|
||||||
|
_, err = Instance.Client.UserV2.DeleteUser(CTX, &user.DeleteUserRequest{UserId: createdUser.ID})
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateUser_scopedExternalID(t *testing.T) {
|
||||||
|
_, err := Instance.Client.Mgmt.SetUserMetadata(CTX, &management.SetUserMetadataRequest{
|
||||||
|
Id: Instance.Users.Get(integration.UserTypeOrgOwner).ID,
|
||||||
|
Key: "urn:zitadel:scim:provisioning_domain",
|
||||||
|
Value: []byte("fooBar"),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
createdUser, err := Instance.Client.SCIM.Users.Create(CTX, Instance.DefaultOrg.Id, fullUserJson)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// unscoped externalID should not exist
|
||||||
|
_, err = Instance.Client.Mgmt.GetUserMetadata(CTX, &management.GetUserMetadataRequest{
|
||||||
|
Id: createdUser.ID,
|
||||||
|
Key: "urn:zitadel:scim:externalId",
|
||||||
|
})
|
||||||
|
integration.AssertGrpcStatus(t, codes.NotFound, err)
|
||||||
|
|
||||||
|
// scoped externalID should exist
|
||||||
|
md, err := Instance.Client.Mgmt.GetUserMetadata(CTX, &management.GetUserMetadataRequest{
|
||||||
|
Id: createdUser.ID,
|
||||||
|
Key: "urn:zitadel:scim:fooBar:externalId",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "701984", string(md.Metadata.Value))
|
||||||
|
|
||||||
|
_, err = Instance.Client.UserV2.DeleteUser(CTX, &user.DeleteUserRequest{UserId: createdUser.ID})
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
23
internal/api/scim/metadata/context.go
Normal file
23
internal/api/scim/metadata/context.go
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
package metadata
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
)
|
||||||
|
|
||||||
|
type provisioningDomainKeyType struct{}
|
||||||
|
|
||||||
|
var provisioningDomainKey provisioningDomainKeyType
|
||||||
|
|
||||||
|
type ScimContextData struct {
|
||||||
|
ProvisioningDomain string
|
||||||
|
ExternalIDScopedMetadataKey ScopedKey
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetScimContextData(ctx context.Context, data ScimContextData) context.Context {
|
||||||
|
return context.WithValue(ctx, provisioningDomainKey, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetScimContextData(ctx context.Context) ScimContextData {
|
||||||
|
data, _ := ctx.Value(provisioningDomainKey).(ScimContextData)
|
||||||
|
return data
|
||||||
|
}
|
60
internal/api/scim/metadata/metadata.go
Normal file
60
internal/api/scim/metadata/metadata.go
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
package metadata
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Key string
|
||||||
|
type ScopedKey string
|
||||||
|
|
||||||
|
const (
|
||||||
|
externalIdProvisioningDomainPlaceholder = "{provisioningDomain}"
|
||||||
|
|
||||||
|
KeyPrefix = "urn:zitadel:scim:"
|
||||||
|
KeyProvisioningDomain Key = KeyPrefix + "provisioning_domain"
|
||||||
|
|
||||||
|
KeyExternalId Key = KeyPrefix + "externalId"
|
||||||
|
keyScopedExternalIdTemplate = KeyPrefix + externalIdProvisioningDomainPlaceholder + ":externalId"
|
||||||
|
KeyMiddleName Key = KeyPrefix + "name.middleName"
|
||||||
|
KeyHonorificPrefix Key = KeyPrefix + "name.honorificPrefix"
|
||||||
|
KeyHonorificSuffix Key = KeyPrefix + "name.honorificSuffix"
|
||||||
|
KeyProfileUrl Key = KeyPrefix + "profileURL"
|
||||||
|
KeyTitle Key = KeyPrefix + "title"
|
||||||
|
KeyLocale Key = KeyPrefix + "locale"
|
||||||
|
KeyTimezone Key = KeyPrefix + "timezone"
|
||||||
|
KeyIms Key = KeyPrefix + "ims"
|
||||||
|
KeyPhotos Key = KeyPrefix + "photos"
|
||||||
|
KeyAddresses Key = KeyPrefix + "addresses"
|
||||||
|
KeyEntitlements Key = KeyPrefix + "entitlements"
|
||||||
|
KeyRoles Key = KeyPrefix + "roles"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ScimUserRelevantMetadataKeys = []Key{
|
||||||
|
KeyExternalId,
|
||||||
|
KeyMiddleName,
|
||||||
|
KeyHonorificPrefix,
|
||||||
|
KeyHonorificSuffix,
|
||||||
|
KeyProfileUrl,
|
||||||
|
KeyTitle,
|
||||||
|
KeyLocale,
|
||||||
|
KeyTimezone,
|
||||||
|
KeyIms,
|
||||||
|
KeyPhotos,
|
||||||
|
KeyAddresses,
|
||||||
|
KeyEntitlements,
|
||||||
|
KeyRoles,
|
||||||
|
}
|
||||||
|
|
||||||
|
func ScopeExternalIdKey(provisioningDomain string) ScopedKey {
|
||||||
|
return ScopedKey(strings.Replace(keyScopedExternalIdTemplate, externalIdProvisioningDomainPlaceholder, provisioningDomain, 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
func ScopeKey(ctx context.Context, key Key) ScopedKey {
|
||||||
|
// only the externalID is scoped
|
||||||
|
if key == KeyExternalId {
|
||||||
|
return GetScimContextData(ctx).ExternalIDScopedMetadataKey
|
||||||
|
}
|
||||||
|
|
||||||
|
return ScopedKey(key)
|
||||||
|
}
|
53
internal/api/scim/middleware/content_type_middleware.go
Normal file
53
internal/api/scim/middleware/content_type_middleware.go
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"mime"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/zitadel/logging"
|
||||||
|
|
||||||
|
zhttp "github.com/zitadel/zitadel/internal/api/http"
|
||||||
|
"github.com/zitadel/zitadel/internal/api/http/middleware"
|
||||||
|
"github.com/zitadel/zitadel/internal/zerrors"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
ContentTypeScim = "application/scim+json"
|
||||||
|
ContentTypeJson = "application/json"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ContentTypeMiddleware(next middleware.HandlerFuncWithError) middleware.HandlerFuncWithError {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
w.Header().Set(zhttp.ContentType, ContentTypeScim)
|
||||||
|
|
||||||
|
if !validateContentType(r.Header.Get(zhttp.ContentType)) {
|
||||||
|
return zerrors.ThrowInvalidArgumentf(nil, "SMCM-12x4", "Invalid content type header")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !validateContentType(r.Header.Get(zhttp.Accept)) {
|
||||||
|
return zerrors.ThrowInvalidArgumentf(nil, "SMCM-12x5", "Invalid accept header")
|
||||||
|
}
|
||||||
|
|
||||||
|
return next(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateContentType(contentType string) bool {
|
||||||
|
if contentType == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaType, params, err := mime.ParseMediaType(contentType)
|
||||||
|
if err != nil {
|
||||||
|
logging.OnError(err).Warn("failed to parse content type header")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if mediaType != "" && !strings.EqualFold(mediaType, ContentTypeJson) && !strings.EqualFold(mediaType, ContentTypeScim) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
charset, ok := params["charset"]
|
||||||
|
return !ok || strings.EqualFold(charset, "utf-8")
|
||||||
|
}
|
107
internal/api/scim/middleware/content_type_middleware_test.go
Normal file
107
internal/api/scim/middleware/content_type_middleware_test.go
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
zhttp "github.com/zitadel/zitadel/internal/api/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestContentTypeMiddleware(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
contentTypeHeader string
|
||||||
|
acceptHeader string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid",
|
||||||
|
contentTypeHeader: "application/scim+json",
|
||||||
|
acceptHeader: "application/scim+json",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid content type",
|
||||||
|
contentTypeHeader: "application/octet-stream",
|
||||||
|
acceptHeader: "application/json",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid accept",
|
||||||
|
contentTypeHeader: "application/json",
|
||||||
|
acceptHeader: "application/octet-stream",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest("GET", "/", nil)
|
||||||
|
if tt.acceptHeader != "" {
|
||||||
|
req.Header.Set(zhttp.Accept, tt.acceptHeader)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tt.contentTypeHeader != "" {
|
||||||
|
req.Header.Set(zhttp.ContentType, tt.contentTypeHeader)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := ContentTypeMiddleware(func(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
return nil
|
||||||
|
})(httptest.NewRecorder(), req)
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Errorf("ContentTypeMiddleware() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_validateContentType(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
contentType string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty",
|
||||||
|
contentType: "",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "json",
|
||||||
|
contentType: "application/json",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "scim",
|
||||||
|
contentType: "application/scim+json",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "json utf-8",
|
||||||
|
contentType: "application/json; charset=utf-8",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "scim utf-8",
|
||||||
|
contentType: "application/scim+json; charset=utf-8",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown content type",
|
||||||
|
contentType: "application/octet-stream",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown charset",
|
||||||
|
contentType: "application/scim+json; charset=utf-16",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := validateContentType(tt.contentType); got != tt.want {
|
||||||
|
t.Errorf("validateContentType() = %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
54
internal/api/scim/middleware/scim_context_middleware.go
Normal file
54
internal/api/scim/middleware/scim_context_middleware.go
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/zitadel/zitadel/internal/api/authz"
|
||||||
|
zhttp "github.com/zitadel/zitadel/internal/api/http/middleware"
|
||||||
|
smetadata "github.com/zitadel/zitadel/internal/api/scim/metadata"
|
||||||
|
"github.com/zitadel/zitadel/internal/query"
|
||||||
|
"github.com/zitadel/zitadel/internal/zerrors"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ScimContextMiddleware(q *query.Queries) func(next zhttp.HandlerFuncWithError) zhttp.HandlerFuncWithError {
|
||||||
|
return func(next zhttp.HandlerFuncWithError) zhttp.HandlerFuncWithError {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
ctx, err := initScimContext(r.Context(), q)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return next(w, r.WithContext(ctx))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func initScimContext(ctx context.Context, q *query.Queries) (context.Context, error) {
|
||||||
|
data := smetadata.ScimContextData{
|
||||||
|
ProvisioningDomain: "",
|
||||||
|
ExternalIDScopedMetadataKey: smetadata.ScopedKey(smetadata.KeyExternalId),
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx = smetadata.SetScimContextData(ctx, data)
|
||||||
|
|
||||||
|
userID := authz.GetCtxData(ctx).UserID
|
||||||
|
metadata, err := q.GetUserMetadataByKey(ctx, false, userID, string(smetadata.KeyProvisioningDomain), false)
|
||||||
|
if err != nil {
|
||||||
|
if zerrors.IsNotFound(err) {
|
||||||
|
return ctx, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return ctx, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if metadata == nil {
|
||||||
|
return ctx, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
data.ProvisioningDomain = string(metadata.Value)
|
||||||
|
if data.ProvisioningDomain != "" {
|
||||||
|
data.ExternalIDScopedMetadataKey = smetadata.ScopeExternalIdKey(data.ProvisioningDomain)
|
||||||
|
}
|
||||||
|
return smetadata.SetScimContextData(ctx, data), nil
|
||||||
|
}
|
61
internal/api/scim/resources/resource_handler.go
Normal file
61
internal/api/scim/resources/resource_handler.go
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
package resources
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"path"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/zitadel/zitadel/internal/api/authz"
|
||||||
|
"github.com/zitadel/zitadel/internal/api/http"
|
||||||
|
"github.com/zitadel/zitadel/internal/api/scim/schemas"
|
||||||
|
"github.com/zitadel/zitadel/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ResourceHandler[T ResourceHolder] interface {
|
||||||
|
ResourceNameSingular() schemas.ScimResourceTypeSingular
|
||||||
|
ResourceNamePlural() schemas.ScimResourceTypePlural
|
||||||
|
SchemaType() schemas.ScimSchemaType
|
||||||
|
NewResource() T
|
||||||
|
|
||||||
|
Create(ctx context.Context, resource T) (T, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Resource struct {
|
||||||
|
Schemas []schemas.ScimSchemaType `json:"schemas"`
|
||||||
|
Meta *ResourceMeta `json:"meta"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResourceMeta struct {
|
||||||
|
ResourceType schemas.ScimResourceTypeSingular `json:"resourceType"`
|
||||||
|
Created time.Time `json:"created"`
|
||||||
|
LastModified time.Time `json:"lastModified"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
Location string `json:"location"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResourceHolder interface {
|
||||||
|
GetResource() *Resource
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildResource[T ResourceHolder](ctx context.Context, handler ResourceHandler[T], details *domain.ObjectDetails) *Resource {
|
||||||
|
created := details.CreationDate.UTC()
|
||||||
|
if created.IsZero() {
|
||||||
|
created = details.EventDate.UTC()
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Resource{
|
||||||
|
Schemas: []schemas.ScimSchemaType{handler.SchemaType()},
|
||||||
|
Meta: &ResourceMeta{
|
||||||
|
ResourceType: handler.ResourceNameSingular(),
|
||||||
|
Created: created,
|
||||||
|
LastModified: details.EventDate.UTC(),
|
||||||
|
Version: strconv.FormatUint(details.Sequence, 10),
|
||||||
|
Location: buildLocation(ctx, handler, details.ID),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildLocation[T ResourceHolder](ctx context.Context, handler ResourceHandler[T], id string) string {
|
||||||
|
return http.DomainContext(ctx).Origin() + path.Join(schemas.HandlerPrefix, authz.GetCtxData(ctx).OrgID, string(handler.ResourceNamePlural()), id)
|
||||||
|
}
|
69
internal/api/scim/resources/resource_handler_adapter.go
Normal file
69
internal/api/scim/resources/resource_handler_adapter.go
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
package resources
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"slices"
|
||||||
|
|
||||||
|
"github.com/zitadel/zitadel/internal/api/scim/schemas"
|
||||||
|
"github.com/zitadel/zitadel/internal/api/scim/serrors"
|
||||||
|
"github.com/zitadel/zitadel/internal/zerrors"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ResourceHandlerAdapter[T ResourceHolder] struct {
|
||||||
|
handler ResourceHandler[T]
|
||||||
|
}
|
||||||
|
|
||||||
|
type ListRequest struct {
|
||||||
|
// Count An integer indicating the desired maximum number of query results per page. OPTIONAL.
|
||||||
|
Count uint64 `json:"count" schema:"count"`
|
||||||
|
|
||||||
|
// StartIndex An integer indicating the 1-based index of the first query result. Optional.
|
||||||
|
StartIndex uint64 `json:"startIndex" schema:"startIndex"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ListResponse[T any] struct {
|
||||||
|
Schemas []schemas.ScimSchemaType `json:"schemas"`
|
||||||
|
ItemsPerPage uint64 `json:"itemsPerPage"`
|
||||||
|
TotalResults uint64 `json:"totalResults"`
|
||||||
|
StartIndex uint64 `json:"startIndex"`
|
||||||
|
Resources []T `json:"Resources"` // according to the rfc this is the only field in PascalCase...
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewResourceHandlerAdapter[T ResourceHolder](handler ResourceHandler[T]) *ResourceHandlerAdapter[T] {
|
||||||
|
return &ResourceHandlerAdapter[T]{
|
||||||
|
handler,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (adapter *ResourceHandlerAdapter[T]) Create(r *http.Request) (T, error) {
|
||||||
|
entity, err := adapter.readEntityFromBody(r)
|
||||||
|
if err != nil {
|
||||||
|
return entity, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return adapter.handler.Create(r.Context(), entity)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (adapter *ResourceHandlerAdapter[T]) readEntityFromBody(r *http.Request) (T, error) {
|
||||||
|
entity := adapter.handler.NewResource()
|
||||||
|
err := json.NewDecoder(r.Body).Decode(entity)
|
||||||
|
if err != nil {
|
||||||
|
if zerrors.IsZitadelError(err) {
|
||||||
|
return entity, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return entity, serrors.ThrowInvalidSyntax(zerrors.ThrowInvalidArgumentf(nil, "SCIM-ucrjson", "Could not deserialize json: %v", err.Error()))
|
||||||
|
}
|
||||||
|
|
||||||
|
resource := entity.GetResource()
|
||||||
|
if resource == nil {
|
||||||
|
return entity, serrors.ThrowInvalidSyntax(zerrors.ThrowInvalidArgument(nil, "SCIM-xxrjson", "Could not get resource, is the schema correct?"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if !slices.Contains(resource.Schemas, adapter.handler.SchemaType()) {
|
||||||
|
return entity, serrors.ThrowInvalidSyntax(zerrors.ThrowInvalidArgumentf(nil, "SCIM-xxrschema", "Expected schema %v is not provided", adapter.handler.SchemaType()))
|
||||||
|
}
|
||||||
|
|
||||||
|
return entity, nil
|
||||||
|
}
|
146
internal/api/scim/resources/user.go
Normal file
146
internal/api/scim/resources/user.go
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
package resources
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"golang.org/x/text/language"
|
||||||
|
|
||||||
|
"github.com/zitadel/zitadel/internal/api/authz"
|
||||||
|
scim_config "github.com/zitadel/zitadel/internal/api/scim/config"
|
||||||
|
schemas2 "github.com/zitadel/zitadel/internal/api/scim/schemas"
|
||||||
|
"github.com/zitadel/zitadel/internal/command"
|
||||||
|
"github.com/zitadel/zitadel/internal/crypto"
|
||||||
|
"github.com/zitadel/zitadel/internal/query"
|
||||||
|
)
|
||||||
|
|
||||||
|
type UsersHandler struct {
|
||||||
|
command *command.Commands
|
||||||
|
query *query.Queries
|
||||||
|
userCodeAlg crypto.EncryptionAlgorithm
|
||||||
|
config *scim_config.Config
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScimUser struct {
|
||||||
|
*Resource
|
||||||
|
ID string `json:"id"`
|
||||||
|
ExternalID string `json:"externalId,omitempty"`
|
||||||
|
UserName string `json:"userName,omitempty"`
|
||||||
|
Name *ScimUserName `json:"name,omitempty"`
|
||||||
|
DisplayName string `json:"displayName,omitempty"`
|
||||||
|
NickName string `json:"nickName,omitempty"`
|
||||||
|
ProfileUrl *schemas2.HttpURL `json:"profileUrl,omitempty"`
|
||||||
|
Title string `json:"title,omitempty"`
|
||||||
|
PreferredLanguage language.Tag `json:"preferredLanguage,omitempty"`
|
||||||
|
Locale string `json:"locale,omitempty"`
|
||||||
|
Timezone string `json:"timezone,omitempty"`
|
||||||
|
Active bool `json:"active,omitempty"`
|
||||||
|
Emails []*ScimEmail `json:"emails,omitempty"`
|
||||||
|
PhoneNumbers []*ScimPhoneNumber `json:"phoneNumbers,omitempty"`
|
||||||
|
Password *schemas2.WriteOnlyString `json:"password,omitempty"`
|
||||||
|
Ims []*ScimIms `json:"ims,omitempty"`
|
||||||
|
Addresses []*ScimAddress `json:"addresses,omitempty"`
|
||||||
|
Photos []*ScimPhoto `json:"photos,omitempty"`
|
||||||
|
Entitlements []*ScimEntitlement `json:"entitlements,omitempty"`
|
||||||
|
Roles []*ScimRole `json:"roles,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScimEntitlement struct {
|
||||||
|
Value string `json:"value,omitempty"`
|
||||||
|
Display string `json:"display,omitempty"`
|
||||||
|
Type string `json:"type,omitempty"`
|
||||||
|
Primary bool `json:"primary,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScimRole struct {
|
||||||
|
Value string `json:"value,omitempty"`
|
||||||
|
Display string `json:"display,omitempty"`
|
||||||
|
Type string `json:"type,omitempty"`
|
||||||
|
Primary bool `json:"primary,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScimPhoto struct {
|
||||||
|
Value schemas2.HttpURL `json:"value"`
|
||||||
|
Display string `json:"display,omitempty"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Primary bool `json:"primary,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScimAddress struct {
|
||||||
|
Type string `json:"type,omitempty"`
|
||||||
|
StreetAddress string `json:"streetAddress,omitempty"`
|
||||||
|
Locality string `json:"locality,omitempty"`
|
||||||
|
Region string `json:"region,omitempty"`
|
||||||
|
PostalCode string `json:"postalCode,omitempty"`
|
||||||
|
Country string `json:"country,omitempty"`
|
||||||
|
Formatted string `json:"formatted,omitempty"`
|
||||||
|
Primary bool `json:"primary,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScimIms struct {
|
||||||
|
Value string `json:"value"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScimEmail struct {
|
||||||
|
Value string `json:"value"`
|
||||||
|
Primary bool `json:"primary"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScimPhoneNumber struct {
|
||||||
|
Value string `json:"value"`
|
||||||
|
Primary bool `json:"primary"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScimUserName struct {
|
||||||
|
Formatted string `json:"formatted,omitempty"`
|
||||||
|
FamilyName string `json:"familyName,omitempty"`
|
||||||
|
GivenName string `json:"givenName,omitempty"`
|
||||||
|
MiddleName string `json:"middleName,omitempty"`
|
||||||
|
HonorificPrefix string `json:"honorificPrefix,omitempty"`
|
||||||
|
HonorificSuffix string `json:"honorificSuffix,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewUsersHandler(
|
||||||
|
command *command.Commands,
|
||||||
|
query *query.Queries,
|
||||||
|
userCodeAlg crypto.EncryptionAlgorithm,
|
||||||
|
config *scim_config.Config) ResourceHandler[*ScimUser] {
|
||||||
|
return &UsersHandler{command, query, userCodeAlg, config}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *UsersHandler) ResourceNameSingular() schemas2.ScimResourceTypeSingular {
|
||||||
|
return schemas2.UserResourceType
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *UsersHandler) ResourceNamePlural() schemas2.ScimResourceTypePlural {
|
||||||
|
return schemas2.UsersResourceType
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ScimUser) GetResource() *Resource {
|
||||||
|
return u.Resource
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *UsersHandler) NewResource() *ScimUser {
|
||||||
|
return new(ScimUser)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *UsersHandler) SchemaType() schemas2.ScimSchemaType {
|
||||||
|
return schemas2.IdUser
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *UsersHandler) Create(ctx context.Context, user *ScimUser) (*ScimUser, error) {
|
||||||
|
orgID := authz.GetCtxData(ctx).OrgID
|
||||||
|
addHuman, err := h.mapToAddHuman(ctx, user)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = h.command.AddUserHuman(ctx, orgID, addHuman, true, h.userCodeAlg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
user.ID = addHuman.Details.ID
|
||||||
|
user.Resource = buildResource(ctx, h, addHuman.Details)
|
||||||
|
return user, err
|
||||||
|
}
|
81
internal/api/scim/resources/user_mapping.go
Normal file
81
internal/api/scim/resources/user_mapping.go
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
package resources
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"golang.org/x/text/language"
|
||||||
|
|
||||||
|
"github.com/zitadel/zitadel/internal/command"
|
||||||
|
"github.com/zitadel/zitadel/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *UsersHandler) mapToAddHuman(ctx context.Context, scimUser *ScimUser) (*command.AddHuman, error) {
|
||||||
|
// zitadel has its own state mechanism
|
||||||
|
// ignore scimUser.Active
|
||||||
|
human := &command.AddHuman{
|
||||||
|
Username: scimUser.UserName,
|
||||||
|
NickName: scimUser.NickName,
|
||||||
|
DisplayName: scimUser.DisplayName,
|
||||||
|
Email: h.mapPrimaryEmail(scimUser),
|
||||||
|
Phone: h.mapPrimaryPhone(scimUser),
|
||||||
|
}
|
||||||
|
|
||||||
|
md, err := h.mapMetadataToCommands(ctx, scimUser)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
human.Metadata = md
|
||||||
|
|
||||||
|
if scimUser.Password != nil {
|
||||||
|
human.Password = scimUser.Password.String()
|
||||||
|
scimUser.Password = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if scimUser.Name != nil {
|
||||||
|
human.FirstName = scimUser.Name.GivenName
|
||||||
|
human.LastName = scimUser.Name.FamilyName
|
||||||
|
|
||||||
|
// the direct mapping displayName => displayName has priority
|
||||||
|
// over the formatted name assignment
|
||||||
|
if human.DisplayName == "" {
|
||||||
|
human.DisplayName = scimUser.Name.Formatted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := domain.LanguageIsDefined(scimUser.PreferredLanguage); err != nil {
|
||||||
|
human.PreferredLanguage = language.English
|
||||||
|
scimUser.PreferredLanguage = language.English
|
||||||
|
}
|
||||||
|
|
||||||
|
return human, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *UsersHandler) mapPrimaryEmail(scimUser *ScimUser) command.Email {
|
||||||
|
for _, email := range scimUser.Emails {
|
||||||
|
if !email.Primary {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
return command.Email{
|
||||||
|
Address: domain.EmailAddress(email.Value),
|
||||||
|
Verified: h.config.EmailVerified,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return command.Email{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *UsersHandler) mapPrimaryPhone(scimUser *ScimUser) command.Phone {
|
||||||
|
for _, phone := range scimUser.PhoneNumbers {
|
||||||
|
if !phone.Primary {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
return command.Phone{
|
||||||
|
Number: domain.PhoneNumber(phone.Value),
|
||||||
|
Verified: h.config.PhoneVerified,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return command.Phone{}
|
||||||
|
}
|
150
internal/api/scim/resources/user_metadata.go
Normal file
150
internal/api/scim/resources/user_metadata.go
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
package resources
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/zitadel/logging"
|
||||||
|
"golang.org/x/text/language"
|
||||||
|
|
||||||
|
"github.com/zitadel/zitadel/internal/api/scim/metadata"
|
||||||
|
"github.com/zitadel/zitadel/internal/api/scim/schemas"
|
||||||
|
"github.com/zitadel/zitadel/internal/api/scim/serrors"
|
||||||
|
"github.com/zitadel/zitadel/internal/command"
|
||||||
|
"github.com/zitadel/zitadel/internal/zerrors"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *UsersHandler) mapMetadataToCommands(ctx context.Context, user *ScimUser) ([]*command.AddMetadataEntry, error) {
|
||||||
|
md := make([]*command.AddMetadataEntry, 0, len(metadata.ScimUserRelevantMetadataKeys))
|
||||||
|
for _, key := range metadata.ScimUserRelevantMetadataKeys {
|
||||||
|
value, err := getValueForMetadataKey(user, key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(value) > 0 {
|
||||||
|
md = append(md, &command.AddMetadataEntry{
|
||||||
|
Key: string(metadata.ScopeKey(ctx, key)),
|
||||||
|
Value: value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return md, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getValueForMetadataKey(user *ScimUser, key metadata.Key) ([]byte, error) {
|
||||||
|
value := getRawValueForMetadataKey(user, key)
|
||||||
|
if value == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch key {
|
||||||
|
// json values
|
||||||
|
case metadata.KeyEntitlements:
|
||||||
|
fallthrough
|
||||||
|
case metadata.KeyIms:
|
||||||
|
fallthrough
|
||||||
|
case metadata.KeyPhotos:
|
||||||
|
fallthrough
|
||||||
|
case metadata.KeyAddresses:
|
||||||
|
fallthrough
|
||||||
|
case metadata.KeyRoles:
|
||||||
|
return json.Marshal(value)
|
||||||
|
|
||||||
|
// http url values
|
||||||
|
case metadata.KeyProfileUrl:
|
||||||
|
return []byte(value.(*schemas.HttpURL).String()), nil
|
||||||
|
|
||||||
|
// raw values
|
||||||
|
case metadata.KeyProvisioningDomain:
|
||||||
|
fallthrough
|
||||||
|
case metadata.KeyExternalId:
|
||||||
|
fallthrough
|
||||||
|
case metadata.KeyMiddleName:
|
||||||
|
fallthrough
|
||||||
|
case metadata.KeyHonorificSuffix:
|
||||||
|
fallthrough
|
||||||
|
case metadata.KeyHonorificPrefix:
|
||||||
|
fallthrough
|
||||||
|
case metadata.KeyTitle:
|
||||||
|
fallthrough
|
||||||
|
case metadata.KeyLocale:
|
||||||
|
fallthrough
|
||||||
|
case metadata.KeyTimezone:
|
||||||
|
valueStr := value.(string)
|
||||||
|
if valueStr == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return []byte(valueStr), validateValueForMetadataKey(valueStr, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
logging.Panicf("Unknown metadata key %s", key)
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateValueForMetadataKey(v string, key metadata.Key) error {
|
||||||
|
//nolint:exhaustive
|
||||||
|
switch key {
|
||||||
|
case metadata.KeyLocale:
|
||||||
|
if _, err := language.Parse(v); err != nil {
|
||||||
|
return serrors.ThrowInvalidValue(zerrors.ThrowInvalidArgument(err, "SCIM-MD11", "Could not parse locale"))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
case metadata.KeyTimezone:
|
||||||
|
if _, err := time.LoadLocation(v); err != nil {
|
||||||
|
return serrors.ThrowInvalidValue(zerrors.ThrowInvalidArgument(err, "SCIM-MD12", "Could not parse timezone"))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getRawValueForMetadataKey(user *ScimUser, key metadata.Key) interface{} {
|
||||||
|
switch key {
|
||||||
|
case metadata.KeyIms:
|
||||||
|
return user.Ims
|
||||||
|
case metadata.KeyPhotos:
|
||||||
|
return user.Photos
|
||||||
|
case metadata.KeyAddresses:
|
||||||
|
return user.Addresses
|
||||||
|
case metadata.KeyEntitlements:
|
||||||
|
return user.Entitlements
|
||||||
|
case metadata.KeyRoles:
|
||||||
|
return user.Roles
|
||||||
|
case metadata.KeyMiddleName:
|
||||||
|
if user.Name == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return user.Name.MiddleName
|
||||||
|
case metadata.KeyHonorificPrefix:
|
||||||
|
if user.Name == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return user.Name.HonorificPrefix
|
||||||
|
case metadata.KeyHonorificSuffix:
|
||||||
|
if user.Name == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return user.Name.HonorificSuffix
|
||||||
|
case metadata.KeyExternalId:
|
||||||
|
return user.ExternalID
|
||||||
|
case metadata.KeyProfileUrl:
|
||||||
|
return user.ProfileUrl
|
||||||
|
case metadata.KeyTitle:
|
||||||
|
return user.Title
|
||||||
|
case metadata.KeyLocale:
|
||||||
|
return user.Locale
|
||||||
|
case metadata.KeyTimezone:
|
||||||
|
return user.Timezone
|
||||||
|
case metadata.KeyProvisioningDomain:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
logging.Panicf("Unknown or unsupported metadata key %s", key)
|
||||||
|
return nil
|
||||||
|
}
|
20
internal/api/scim/schemas/schemas.go
Normal file
20
internal/api/scim/schemas/schemas.go
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
package schemas
|
||||||
|
|
||||||
|
type ScimSchemaType string
|
||||||
|
type ScimResourceTypeSingular string
|
||||||
|
type ScimResourceTypePlural string
|
||||||
|
|
||||||
|
const (
|
||||||
|
idPrefixMessages = "urn:ietf:params:scim:api:messages:2.0:"
|
||||||
|
idPrefixCore = "urn:ietf:params:scim:schemas:core:2.0:"
|
||||||
|
idPrefixZitadelMessages = "urn:ietf:params:scim:api:zitadel:messages:2.0:"
|
||||||
|
|
||||||
|
IdUser ScimSchemaType = idPrefixCore + "User"
|
||||||
|
IdError ScimSchemaType = idPrefixMessages + "Error"
|
||||||
|
IdZitadelErrorDetail ScimSchemaType = idPrefixZitadelMessages + "ErrorDetail"
|
||||||
|
|
||||||
|
UserResourceType ScimResourceTypeSingular = "User"
|
||||||
|
UsersResourceType ScimResourceTypePlural = "Users"
|
||||||
|
|
||||||
|
HandlerPrefix = "/scim/v2"
|
||||||
|
)
|
28
internal/api/scim/schemas/string.go
Normal file
28
internal/api/scim/schemas/string.go
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
package schemas
|
||||||
|
|
||||||
|
import "encoding/json"
|
||||||
|
|
||||||
|
// WriteOnlyString a write only string is not serializable to json.
|
||||||
|
// in the SCIM RFC it has a mutability of writeOnly.
|
||||||
|
// This increases security to really ensure this is never sent to a client.
|
||||||
|
type WriteOnlyString string
|
||||||
|
|
||||||
|
func NewWriteOnlyString(s string) *WriteOnlyString {
|
||||||
|
wos := WriteOnlyString(s)
|
||||||
|
return &wos
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WriteOnlyString) MarshalJSON() ([]byte, error) {
|
||||||
|
return []byte("null"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WriteOnlyString) UnmarshalJSON(bytes []byte) error {
|
||||||
|
var str string
|
||||||
|
err := json.Unmarshal(bytes, &str)
|
||||||
|
*s = WriteOnlyString(str)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WriteOnlyString) String() string {
|
||||||
|
return string(*s)
|
||||||
|
}
|
70
internal/api/scim/schemas/string_test.go
Normal file
70
internal/api/scim/schemas/string_test.go
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
package schemas
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWriteOnlyString_MarshalJSON(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
s WriteOnlyString
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "always returns null",
|
||||||
|
s: "foo bar",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty string returns null",
|
||||||
|
s: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := json.Marshal(&tt.s)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "null", string(got))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteOnlyString_UnmarshalJSON(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input []byte
|
||||||
|
want WriteOnlyString
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "string",
|
||||||
|
input: []byte(`"fooBar"`),
|
||||||
|
want: "fooBar",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty string",
|
||||||
|
input: []byte(`""`),
|
||||||
|
want: "",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bad format",
|
||||||
|
input: []byte(`"bad "format"`),
|
||||||
|
want: "",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
var got WriteOnlyString
|
||||||
|
err := json.Unmarshal(tt.input, &got)
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Errorf("UnmarshalJSON() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, tt.want, got)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
50
internal/api/scim/schemas/url.go
Normal file
50
internal/api/scim/schemas/url.go
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
package schemas
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
|
"github.com/zitadel/zitadel/internal/zerrors"
|
||||||
|
)
|
||||||
|
|
||||||
|
type HttpURL url.URL
|
||||||
|
|
||||||
|
func ParseHTTPURL(rawURL string) (*HttpURL, error) {
|
||||||
|
parsedURL, err := url.Parse(rawURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
|
||||||
|
return nil, zerrors.ThrowInvalidArgumentf(nil, "SCIM-htturl1", "HTTP URL expected, got %v", parsedURL.Scheme)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (*HttpURL)(parsedURL), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *HttpURL) UnmarshalJSON(data []byte) error {
|
||||||
|
var urlStr string
|
||||||
|
if err := json.Unmarshal(data, &urlStr); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
parsedURL, err := ParseHTTPURL(urlStr)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
*u = *parsedURL
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *HttpURL) MarshalJSON() ([]byte, error) {
|
||||||
|
return json.Marshal(u.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *HttpURL) String() string {
|
||||||
|
if u == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return (*url.URL)(u).String()
|
||||||
|
}
|
182
internal/api/scim/schemas/url_test.go
Normal file
182
internal/api/scim/schemas/url_test.go
Normal file
@ -0,0 +1,182 @@
|
|||||||
|
package schemas
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/goccy/go-json"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/zitadel/logging"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHttpURL_MarshalJSON(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
u *HttpURL
|
||||||
|
want []byte
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "http url",
|
||||||
|
u: mustParseURL("http://example.com"),
|
||||||
|
want: []byte(`"http://example.com"`),
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "https url",
|
||||||
|
u: mustParseURL("https://example.com"),
|
||||||
|
want: []byte(`"https://example.com"`),
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := json.Marshal(tt.u)
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Errorf("MarshalJSON() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, string(got), string(tt.want))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHttpURL_UnmarshalJSON(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
data []byte
|
||||||
|
want *HttpURL
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "http url",
|
||||||
|
data: []byte(`"http://example.com"`),
|
||||||
|
want: mustParseURL("http://example.com"),
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "https url",
|
||||||
|
data: []byte(`"https://example.com"`),
|
||||||
|
want: mustParseURL("https://example.com"),
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ftp url should fail",
|
||||||
|
data: []byte(`"ftp://example.com"`),
|
||||||
|
want: nil,
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no url should fail",
|
||||||
|
data: []byte(`"test"`),
|
||||||
|
want: nil,
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "number should fail",
|
||||||
|
data: []byte(`120`),
|
||||||
|
want: nil,
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
url := new(HttpURL)
|
||||||
|
err := json.Unmarshal(tt.data, url)
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Errorf("UnmarshalJSON() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tt.wantErr {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, tt.want.String(), url.String())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHttpURL_String(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
u *HttpURL
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "http url",
|
||||||
|
u: mustParseURL("http://example.com"),
|
||||||
|
want: "http://example.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "https url",
|
||||||
|
u: mustParseURL("https://example.com"),
|
||||||
|
want: "https://example.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nil",
|
||||||
|
u: nil,
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := tt.u.String(); got != tt.want {
|
||||||
|
t.Errorf("String() = %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseHTTPURL(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
rawURL string
|
||||||
|
want *HttpURL
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "http url",
|
||||||
|
rawURL: "http://example.com",
|
||||||
|
want: mustParseURL("http://example.com"),
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "https url",
|
||||||
|
rawURL: "https://example.com",
|
||||||
|
want: mustParseURL("https://example.com"),
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ftp url should fail",
|
||||||
|
rawURL: "ftp://example.com",
|
||||||
|
want: nil,
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no url should fail",
|
||||||
|
rawURL: "test",
|
||||||
|
want: nil,
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := ParseHTTPURL(tt.rawURL)
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Errorf("ParseHTTPURL() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !reflect.DeepEqual(got, tt.want) {
|
||||||
|
t.Errorf("ParseHTTPURL() got = %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustParseURL(rawURL string) *HttpURL {
|
||||||
|
url, err := ParseHTTPURL(rawURL)
|
||||||
|
logging.OnError(err).Fatal("failed to parse URL")
|
||||||
|
return url
|
||||||
|
}
|
140
internal/api/scim/serrors/errors.go
Normal file
140
internal/api/scim/serrors/errors.go
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
package serrors
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/zitadel/logging"
|
||||||
|
"golang.org/x/text/language"
|
||||||
|
|
||||||
|
http_util "github.com/zitadel/zitadel/internal/api/http"
|
||||||
|
zhttp_middleware "github.com/zitadel/zitadel/internal/api/http/middleware"
|
||||||
|
"github.com/zitadel/zitadel/internal/api/scim/schemas"
|
||||||
|
"github.com/zitadel/zitadel/internal/i18n"
|
||||||
|
"github.com/zitadel/zitadel/internal/zerrors"
|
||||||
|
)
|
||||||
|
|
||||||
|
type scimErrorType string
|
||||||
|
|
||||||
|
type wrappedScimError struct {
|
||||||
|
Parent error
|
||||||
|
ScimType scimErrorType
|
||||||
|
}
|
||||||
|
|
||||||
|
type scimError struct {
|
||||||
|
Schemas []schemas.ScimSchemaType `json:"schemas"`
|
||||||
|
ScimType scimErrorType `json:"scimType,omitempty"`
|
||||||
|
Detail string `json:"detail,omitempty"`
|
||||||
|
StatusCode int `json:"-"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
ZitadelDetail *errorDetail `json:"urn:ietf:params:scim:api:zitadel:messages:2.0:ErrorDetail,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type errorDetail struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
// ScimTypeInvalidValue A required value was missing,
|
||||||
|
// or the value specified was not compatible with the operation,
|
||||||
|
// or attribute type (see Section 2.2 of RFC7643),
|
||||||
|
// or resource schema (see Section 4 of RFC7643).
|
||||||
|
ScimTypeInvalidValue scimErrorType = "invalidValue"
|
||||||
|
|
||||||
|
// ScimTypeInvalidSyntax The request body message structure was invalid or did
|
||||||
|
// not conform to the request schema.
|
||||||
|
ScimTypeInvalidSyntax scimErrorType = "invalidSyntax"
|
||||||
|
)
|
||||||
|
|
||||||
|
var translator *i18n.Translator
|
||||||
|
|
||||||
|
func ErrorHandler(next zhttp_middleware.HandlerFuncWithError) http.Handler {
|
||||||
|
var err error
|
||||||
|
translator, err = i18n.NewZitadelTranslator(language.English)
|
||||||
|
logging.OnError(err).Panic("unable to get translator")
|
||||||
|
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err = next(w, r); err == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
scimErr := mapToScimJsonError(r.Context(), err)
|
||||||
|
w.WriteHeader(scimErr.StatusCode)
|
||||||
|
|
||||||
|
jsonErr := json.NewEncoder(w).Encode(scimErr)
|
||||||
|
logging.OnError(jsonErr).Warn("Failed to marshal scim error response")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func ThrowInvalidValue(parent error) error {
|
||||||
|
return &wrappedScimError{
|
||||||
|
Parent: parent,
|
||||||
|
ScimType: ScimTypeInvalidValue,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ThrowInvalidSyntax(parent error) error {
|
||||||
|
return &wrappedScimError{
|
||||||
|
Parent: parent,
|
||||||
|
ScimType: ScimTypeInvalidSyntax,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (err *scimError) Error() string {
|
||||||
|
return fmt.Sprintf("SCIM Error: %s: %s", err.ScimType, err.Detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (err *wrappedScimError) Error() string {
|
||||||
|
return fmt.Sprintf("SCIM Error: %s: %s", err.ScimType, err.Parent.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapToScimJsonError(ctx context.Context, err error) *scimError {
|
||||||
|
scimErr := new(wrappedScimError)
|
||||||
|
if ok := errors.As(err, &scimErr); ok {
|
||||||
|
mappedErr := mapToScimJsonError(ctx, scimErr.Parent)
|
||||||
|
mappedErr.ScimType = scimErr.ScimType
|
||||||
|
return mappedErr
|
||||||
|
}
|
||||||
|
|
||||||
|
zitadelErr := new(zerrors.ZitadelError)
|
||||||
|
if ok := errors.As(err, &zitadelErr); !ok {
|
||||||
|
return &scimError{
|
||||||
|
Schemas: []schemas.ScimSchemaType{schemas.IdError},
|
||||||
|
Detail: "Unknown internal server error",
|
||||||
|
Status: strconv.Itoa(http.StatusInternalServerError),
|
||||||
|
StatusCode: http.StatusInternalServerError,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
statusCode, ok := http_util.ZitadelErrorToHTTPStatusCode(err)
|
||||||
|
if !ok {
|
||||||
|
statusCode = http.StatusInternalServerError
|
||||||
|
}
|
||||||
|
|
||||||
|
localizedMsg := translator.LocalizeFromCtx(ctx, zitadelErr.GetMessage(), nil)
|
||||||
|
return &scimError{
|
||||||
|
Schemas: []schemas.ScimSchemaType{schemas.IdError, schemas.IdZitadelErrorDetail},
|
||||||
|
ScimType: mapErrorToScimErrorType(err),
|
||||||
|
Detail: localizedMsg,
|
||||||
|
StatusCode: statusCode,
|
||||||
|
Status: strconv.Itoa(statusCode),
|
||||||
|
ZitadelDetail: &errorDetail{
|
||||||
|
ID: zitadelErr.GetID(),
|
||||||
|
Message: zitadelErr.GetMessage(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapErrorToScimErrorType(err error) scimErrorType {
|
||||||
|
switch {
|
||||||
|
case zerrors.IsErrorInvalidArgument(err):
|
||||||
|
return ScimTypeInvalidValue
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
110
internal/api/scim/serrors/errors_test.go
Normal file
110
internal/api/scim/serrors/errors_test.go
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
package serrors
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
||||||
|
"github.com/zitadel/zitadel/internal/i18n"
|
||||||
|
"github.com/zitadel/zitadel/internal/zerrors"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestErrorHandler(t *testing.T) {
|
||||||
|
i18n.MustLoadSupportedLanguagesFromDir()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
err error
|
||||||
|
wantStatus int
|
||||||
|
wantBody string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "scim error",
|
||||||
|
err: ThrowInvalidSyntax(zerrors.ThrowInvalidArgument(nil, "FOO", "Invalid syntax")),
|
||||||
|
wantStatus: http.StatusBadRequest,
|
||||||
|
wantBody: `{
|
||||||
|
"schemas":[
|
||||||
|
"urn:ietf:params:scim:api:messages:2.0:Error",
|
||||||
|
"urn:ietf:params:scim:api:zitadel:messages:2.0:ErrorDetail"
|
||||||
|
],
|
||||||
|
"scimType":"invalidSyntax",
|
||||||
|
"detail":"Invalid syntax",
|
||||||
|
"status":"400",
|
||||||
|
"urn:ietf:params:scim:api:zitadel:messages:2.0:ErrorDetail": {
|
||||||
|
"id":"FOO",
|
||||||
|
"message":"Invalid syntax"
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "zitadel error",
|
||||||
|
err: zerrors.ThrowInvalidArgument(nil, "FOO", "Invalid syntax"),
|
||||||
|
wantStatus: http.StatusBadRequest,
|
||||||
|
wantBody: `{
|
||||||
|
"schemas":[
|
||||||
|
"urn:ietf:params:scim:api:messages:2.0:Error",
|
||||||
|
"urn:ietf:params:scim:api:zitadel:messages:2.0:ErrorDetail"
|
||||||
|
],
|
||||||
|
"scimType":"invalidValue",
|
||||||
|
"detail":"Invalid syntax",
|
||||||
|
"status":"400",
|
||||||
|
"urn:ietf:params:scim:api:zitadel:messages:2.0:ErrorDetail": {
|
||||||
|
"id":"FOO",
|
||||||
|
"message":"Invalid syntax"
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "zitadel internal error",
|
||||||
|
err: zerrors.ThrowInternal(nil, "FOO", "Internal error"),
|
||||||
|
wantStatus: http.StatusInternalServerError,
|
||||||
|
wantBody: `{
|
||||||
|
"schemas":[
|
||||||
|
"urn:ietf:params:scim:api:messages:2.0:Error",
|
||||||
|
"urn:ietf:params:scim:api:zitadel:messages:2.0:ErrorDetail"
|
||||||
|
],
|
||||||
|
"detail":"Internal error",
|
||||||
|
"status":"500",
|
||||||
|
"urn:ietf:params:scim:api:zitadel:messages:2.0:ErrorDetail": {
|
||||||
|
"id":"FOO",
|
||||||
|
"message":"Internal error"
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown error",
|
||||||
|
err: errors.New("FOO"),
|
||||||
|
wantStatus: http.StatusInternalServerError,
|
||||||
|
wantBody: `{
|
||||||
|
"schemas":[
|
||||||
|
"urn:ietf:params:scim:api:messages:2.0:Error"
|
||||||
|
],
|
||||||
|
"detail":"Unknown internal server error",
|
||||||
|
"status":"500"
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no error",
|
||||||
|
err: nil,
|
||||||
|
wantStatus: http.StatusOK,
|
||||||
|
wantBody: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest("GET", "/", nil)
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
ErrorHandler(func(http.ResponseWriter, *http.Request) error {
|
||||||
|
return tt.err
|
||||||
|
}).ServeHTTP(recorder, req)
|
||||||
|
assert.Equal(t, tt.wantStatus, recorder.Code)
|
||||||
|
|
||||||
|
if tt.wantBody != "" {
|
||||||
|
assert.JSONEq(t, tt.wantBody, recorder.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
74
internal/api/scim/server.go
Normal file
74
internal/api/scim/server.go
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
package scim
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"path"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
"github.com/zitadel/logging"
|
||||||
|
|
||||||
|
"github.com/zitadel/zitadel/internal/api/authz"
|
||||||
|
zhttp "github.com/zitadel/zitadel/internal/api/http"
|
||||||
|
zhttp_middlware "github.com/zitadel/zitadel/internal/api/http/middleware"
|
||||||
|
sconfig "github.com/zitadel/zitadel/internal/api/scim/config"
|
||||||
|
smiddleware "github.com/zitadel/zitadel/internal/api/scim/middleware"
|
||||||
|
sresources "github.com/zitadel/zitadel/internal/api/scim/resources"
|
||||||
|
"github.com/zitadel/zitadel/internal/api/scim/schemas"
|
||||||
|
"github.com/zitadel/zitadel/internal/api/scim/serrors"
|
||||||
|
"github.com/zitadel/zitadel/internal/command"
|
||||||
|
"github.com/zitadel/zitadel/internal/crypto"
|
||||||
|
"github.com/zitadel/zitadel/internal/query"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewServer(
|
||||||
|
command *command.Commands,
|
||||||
|
query *query.Queries,
|
||||||
|
verifier *authz.ApiTokenVerifier,
|
||||||
|
userCodeAlg crypto.EncryptionAlgorithm,
|
||||||
|
config *sconfig.Config,
|
||||||
|
middlewares ...zhttp_middlware.MiddlewareWithErrorFunc) http.Handler {
|
||||||
|
verifier.RegisterServer("SCIM-V2", schemas.HandlerPrefix, AuthMapping)
|
||||||
|
return buildHandler(command, query, userCodeAlg, config, middlewares...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildHandler(
|
||||||
|
command *command.Commands,
|
||||||
|
query *query.Queries,
|
||||||
|
userCodeAlg crypto.EncryptionAlgorithm,
|
||||||
|
cfg *sconfig.Config,
|
||||||
|
middlewares ...zhttp_middlware.MiddlewareWithErrorFunc) http.Handler {
|
||||||
|
|
||||||
|
router := mux.NewRouter()
|
||||||
|
|
||||||
|
// content type middleware needs to run at the very beginning to correctly set content types of errors
|
||||||
|
middlewares = append([]zhttp_middlware.MiddlewareWithErrorFunc{smiddleware.ContentTypeMiddleware}, middlewares...)
|
||||||
|
middlewares = append(middlewares, smiddleware.ScimContextMiddleware(query))
|
||||||
|
scimMiddleware := zhttp_middlware.ChainedWithErrorHandler(serrors.ErrorHandler, middlewares...)
|
||||||
|
mapResource(router, scimMiddleware, sresources.NewUsersHandler(command, query, userCodeAlg, cfg))
|
||||||
|
return router
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapResource[T sresources.ResourceHolder](router *mux.Router, mw zhttp_middlware.ErrorHandlerFunc, handler sresources.ResourceHandler[T]) {
|
||||||
|
adapter := sresources.NewResourceHandlerAdapter[T](handler)
|
||||||
|
resourceRouter := router.PathPrefix("/" + path.Join(zhttp.OrgIdInPathVariable, string(handler.ResourceNamePlural()))).Subrouter()
|
||||||
|
|
||||||
|
resourceRouter.Handle("", mw(handleResourceCreatedResponse(adapter.Create))).Methods(http.MethodPost)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleResourceCreatedResponse[T sresources.ResourceHolder](next func(*http.Request) (T, error)) zhttp_middlware.HandlerFuncWithError {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
entity, err := next(r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
resource := entity.GetResource()
|
||||||
|
w.Header().Set(zhttp.Location, resource.Meta.Location)
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
|
||||||
|
err = json.NewEncoder(w).Encode(entity)
|
||||||
|
logging.OnError(err).Warn("scim json response encoding failed")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
@ -6,6 +6,8 @@ import (
|
|||||||
|
|
||||||
"github.com/pmezard/go-difflib/difflib"
|
"github.com/pmezard/go-difflib/difflib"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
"google.golang.org/protobuf/encoding/protojson"
|
"google.golang.org/protobuf/encoding/protojson"
|
||||||
"google.golang.org/protobuf/proto"
|
"google.golang.org/protobuf/proto"
|
||||||
"google.golang.org/protobuf/types/known/timestamppb"
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
@ -128,6 +130,13 @@ func AssertResourceListDetails[D ResourceListDetailsMsg](t assert.TestingT, expe
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func AssertGrpcStatus(t assert.TestingT, expected codes.Code, err error) {
|
||||||
|
assert.Error(t, err)
|
||||||
|
statusErr, ok := status.FromError(err)
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.Equal(t, expected, statusErr.Code())
|
||||||
|
}
|
||||||
|
|
||||||
// EqualProto is inspired by [assert.Equal], only that it tests equality of a proto message.
|
// EqualProto is inspired by [assert.Equal], only that it tests equality of a proto message.
|
||||||
// A message diff is printed on the error test log if the messages are not equal.
|
// A message diff is printed on the error test log if the messages are not equal.
|
||||||
//
|
//
|
||||||
@ -160,3 +169,9 @@ func diffProto(expected, actual proto.Message) string {
|
|||||||
}
|
}
|
||||||
return "\n\nDiff:\n" + diff
|
return "\n\nDiff:\n" + diff
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func AssertMapContains[M ~map[K]V, K comparable, V any](t *testing.T, m M, key K, expectedValue V) {
|
||||||
|
val, exists := m[key]
|
||||||
|
assert.True(t, exists, "Key '%s' should exist in the map", key)
|
||||||
|
assert.Equal(t, expectedValue, val, "Key '%s' should have value '%d'", key, expectedValue)
|
||||||
|
}
|
||||||
|
@ -17,6 +17,7 @@ import (
|
|||||||
"google.golang.org/protobuf/types/known/structpb"
|
"google.golang.org/protobuf/types/known/structpb"
|
||||||
|
|
||||||
"github.com/zitadel/zitadel/internal/domain"
|
"github.com/zitadel/zitadel/internal/domain"
|
||||||
|
"github.com/zitadel/zitadel/internal/integration/scim"
|
||||||
"github.com/zitadel/zitadel/pkg/grpc/admin"
|
"github.com/zitadel/zitadel/pkg/grpc/admin"
|
||||||
"github.com/zitadel/zitadel/pkg/grpc/auth"
|
"github.com/zitadel/zitadel/pkg/grpc/auth"
|
||||||
"github.com/zitadel/zitadel/pkg/grpc/feature/v2"
|
"github.com/zitadel/zitadel/pkg/grpc/feature/v2"
|
||||||
@ -67,6 +68,7 @@ type Client struct {
|
|||||||
IDPv2 idp_pb.IdentityProviderServiceClient
|
IDPv2 idp_pb.IdentityProviderServiceClient
|
||||||
UserV3Alpha user_v3alpha.ZITADELUsersClient
|
UserV3Alpha user_v3alpha.ZITADELUsersClient
|
||||||
SAMLv2 saml_pb.SAMLServiceClient
|
SAMLv2 saml_pb.SAMLServiceClient
|
||||||
|
SCIM *scim.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
func newClient(ctx context.Context, target string) (*Client, error) {
|
func newClient(ctx context.Context, target string) (*Client, error) {
|
||||||
@ -99,6 +101,7 @@ func newClient(ctx context.Context, target string) (*Client, error) {
|
|||||||
IDPv2: idp_pb.NewIdentityProviderServiceClient(cc),
|
IDPv2: idp_pb.NewIdentityProviderServiceClient(cc),
|
||||||
UserV3Alpha: user_v3alpha.NewZITADELUsersClient(cc),
|
UserV3Alpha: user_v3alpha.NewZITADELUsersClient(cc),
|
||||||
SAMLv2: saml_pb.NewSAMLServiceClient(cc),
|
SAMLv2: saml_pb.NewSAMLServiceClient(cc),
|
||||||
|
SCIM: scim.NewScimClient(target),
|
||||||
}
|
}
|
||||||
return client, client.pollHealth(ctx)
|
return client, client.pollHealth(ctx)
|
||||||
}
|
}
|
||||||
|
133
internal/integration/scim/client.go
Normal file
133
internal/integration/scim/client.go
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
package scim
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"path"
|
||||||
|
|
||||||
|
"github.com/zitadel/logging"
|
||||||
|
"google.golang.org/grpc/metadata"
|
||||||
|
|
||||||
|
zhttp "github.com/zitadel/zitadel/internal/api/http"
|
||||||
|
"github.com/zitadel/zitadel/internal/api/scim/middleware"
|
||||||
|
"github.com/zitadel/zitadel/internal/api/scim/resources"
|
||||||
|
"github.com/zitadel/zitadel/internal/api/scim/schemas"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
Users *ResourceClient
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResourceClient struct {
|
||||||
|
client *http.Client
|
||||||
|
baseUrl string
|
||||||
|
resourceName string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScimError struct {
|
||||||
|
Schemas []string `json:"schemas"`
|
||||||
|
ScimType string `json:"scimType"`
|
||||||
|
Detail string `json:"detail"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
ZitadelDetail *ZitadelErrorDetail `json:"urn:ietf:params:scim:api:zitadel:messages:2.0:ErrorDetail,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ZitadelErrorDetail struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewScimClient(target string) *Client {
|
||||||
|
target = "http://" + target + schemas.HandlerPrefix
|
||||||
|
client := &http.Client{}
|
||||||
|
return &Client{
|
||||||
|
Users: &ResourceClient{
|
||||||
|
client: client,
|
||||||
|
baseUrl: target,
|
||||||
|
resourceName: "Users",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ResourceClient) Create(ctx context.Context, orgID string, body []byte) (*resources.ScimUser, error) {
|
||||||
|
user := new(resources.ScimUser)
|
||||||
|
err := c.doWithBody(ctx, http.MethodPost, orgID, "", bytes.NewReader(body), user)
|
||||||
|
return user, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ResourceClient) doWithBody(ctx context.Context, method, orgID, url string, body io.Reader, responseEntity interface{}) error {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, method, c.buildURL(orgID, url), body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set(zhttp.ContentType, middleware.ContentTypeScim)
|
||||||
|
return c.doReq(req, responseEntity)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ResourceClient) doReq(req *http.Request, responseEntity interface{}) error {
|
||||||
|
addTokenAsHeader(req)
|
||||||
|
|
||||||
|
resp, err := c.client.Do(req)
|
||||||
|
defer func() {
|
||||||
|
err := resp.Body.Close()
|
||||||
|
logging.OnError(err).Error("Failed to close response body")
|
||||||
|
}()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resp.StatusCode / 100) != 2 {
|
||||||
|
return readScimError(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
if responseEntity == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
err = readJson(responseEntity, resp)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func addTokenAsHeader(req *http.Request) {
|
||||||
|
md, ok := metadata.FromOutgoingContext(req.Context())
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Authorization", md.Get("Authorization")[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
func readJson(entity interface{}, resp *http.Response) error {
|
||||||
|
defer func(body io.ReadCloser) {
|
||||||
|
err := body.Close()
|
||||||
|
logging.OnError(err).Panic("Failed to close response body")
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
|
err := json.NewDecoder(resp.Body).Decode(entity)
|
||||||
|
logging.OnError(err).Panic("Failed decoding entity")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func readScimError(resp *http.Response) error {
|
||||||
|
scimErr := new(ScimError)
|
||||||
|
readErr := readJson(scimErr, resp)
|
||||||
|
logging.OnError(readErr).Panic("Failed reading scim error")
|
||||||
|
return scimErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ResourceClient) buildURL(orgID, segment string) string {
|
||||||
|
if segment == "" {
|
||||||
|
return c.baseUrl + "/" + path.Join(orgID, c.resourceName)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.baseUrl + "/" + path.Join(orgID, c.resourceName, segment)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (err *ScimError) Error() string {
|
||||||
|
return "scim error: " + err.Detail
|
||||||
|
}
|
@ -79,3 +79,8 @@ func (err *ZitadelError) As(target interface{}) bool {
|
|||||||
reflect.Indirect(reflect.ValueOf(target)).Set(reflect.ValueOf(err))
|
reflect.Indirect(reflect.ValueOf(target)).Set(reflect.ValueOf(err))
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func IsZitadelError(err error) bool {
|
||||||
|
zitadelErr := new(ZitadelError)
|
||||||
|
return errors.As(err, &zitadelErr)
|
||||||
|
}
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
package zerrors_test
|
package zerrors_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@ -17,3 +18,27 @@ func TestErrorMethod(t *testing.T) {
|
|||||||
subExptected := "ID=subID Message=subMsg Parent=(ID=id Message=msg)"
|
subExptected := "ID=subID Message=subMsg Parent=(ID=id Message=msg)"
|
||||||
assert.Equal(t, subExptected, err.Error())
|
assert.Equal(t, subExptected, err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestIsZitadelError(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
err error
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "zitadel error",
|
||||||
|
err: zerrors.ThrowInvalidArgument(nil, "id", "msg"),
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "other error",
|
||||||
|
err: errors.New("just a random error"),
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
assert.Equalf(t, tt.want, zerrors.IsZitadelError(tt.err), "IsZitadelError(%v)", tt.err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user