Merge branch 'main' into next

# Conflicts:
#	cmd/setup/config.go
#	cmd/setup/setup.go
#	internal/auth/repository/eventsourcing/eventstore/auth_request.go
This commit is contained in:
Livio Spring 2025-01-20 14:22:24 +01:00
commit a53fc5f5fa
No known key found for this signature in database
240 changed files with 8802 additions and 1759 deletions

View File

@ -16,6 +16,7 @@ If you are using Zitadel, please consider adding yourself as a user with a quick
| devOS: Sanity Edition | [@devOS-Sanity-Edition](https://github.com/devOS-Sanity-Edition) | Uses SSO Auth for every piece of our internal and external infrastructure | | devOS: Sanity Edition | [@devOS-Sanity-Edition](https://github.com/devOS-Sanity-Edition) | Uses SSO Auth for every piece of our internal and external infrastructure |
| CNAP.tech | [@cnap-tech](https://github.com/cnap-tech) | Using Zitadel for authentication and authorization in cloud-native applications | | CNAP.tech | [@cnap-tech](https://github.com/cnap-tech) | Using Zitadel for authentication and authorization in cloud-native applications |
| Minekube | [@minekube](https://github.com/minekube) | Leveraging Zitadel for secure user authentication in gaming infrastructure | | Minekube | [@minekube](https://github.com/minekube) | Leveraging Zitadel for secure user authentication in gaming infrastructure |
| Dribdat | [@dribdat](https://github.com/dribdat) | Educating people about strong auth and resilient identity at hackathons |
| Micromate | [@sschoeb](https://github.com/sschoeb) | Using Zitadel for authentication and authorization for learners and managers in our digital learning assistant as well as in the Micromate manage platform | | Micromate | [@sschoeb](https://github.com/sschoeb) | Using Zitadel for authentication and authorization for learners and managers in our digital learning assistant as well as in the Micromate manage platform |
| Smat.io | [@smatio](https://github.com/smatio) - [@lukasver](https://github.com/lukasver) | Zitadel for authentication in cloud applications while offering B2B portfolio management solutions for professional investors | | Smat.io | [@smatio](https://github.com/smatio) - [@lukasver](https://github.com/lukasver) | Zitadel for authentication in cloud applications while offering B2B portfolio management solutions for professional investors |
|hirschengraben | [hirschengraben.io](hirschengraben.io) | Using Zitadel as IDP for a multitenant B2B dispatch app for bike messengers | |hirschengraben | [hirschengraben.io](hirschengraben.io) | Using Zitadel as IDP for a multitenant B2B dispatch app for bike messengers |

View File

@ -110,24 +110,13 @@ PublicHostHeaders: # ZITADEL_PUBLICHOSTHEADERS
WebAuthNName: ZITADEL # ZITADEL_WEBAUTHNNAME WebAuthNName: ZITADEL # ZITADEL_WEBAUTHNNAME
Database: Database:
# ZITADEL manages three database connection pools.
# The *ConnRatio settings define the ratio of how many connections from
# MaxOpenConns and MaxIdleConns are used to push events and spool projections.
# Remaining connection are used for queries (search).
# Values may not be negative and the sum of the ratios must always be less than 1.
# For example this defaults define 15 MaxOpenConns overall.
# - 15*0.2=3 connections are allocated to the event pusher;
# - 15*0.135=2 connections are allocated to the projection spooler;
# - 15-(3+2)=10 connections are remaining for queries;
EventPushConnRatio: 0.2 # ZITADEL_DATABASE_COCKROACH_EVENTPUSHCONNRATIO
ProjectionSpoolerConnRatio: 0.135 # ZITADEL_DATABASE_COCKROACH_PROJECTIONSPOOLERCONNRATIO
# CockroachDB is the default database of ZITADEL # CockroachDB is the default database of ZITADEL
cockroach: cockroach:
Host: localhost # ZITADEL_DATABASE_COCKROACH_HOST Host: localhost # ZITADEL_DATABASE_COCKROACH_HOST
Port: 26257 # ZITADEL_DATABASE_COCKROACH_PORT Port: 26257 # ZITADEL_DATABASE_COCKROACH_PORT
Database: zitadel # ZITADEL_DATABASE_COCKROACH_DATABASE Database: zitadel # ZITADEL_DATABASE_COCKROACH_DATABASE
MaxOpenConns: 15 # ZITADEL_DATABASE_COCKROACH_MAXOPENCONNS MaxOpenConns: 5 # ZITADEL_DATABASE_COCKROACH_MAXOPENCONNS
MaxIdleConns: 12 # ZITADEL_DATABASE_COCKROACH_MAXIDLECONNS MaxIdleConns: 2 # ZITADEL_DATABASE_COCKROACH_MAXIDLECONNS
MaxConnLifetime: 30m # ZITADEL_DATABASE_COCKROACH_MAXCONNLIFETIME MaxConnLifetime: 30m # ZITADEL_DATABASE_COCKROACH_MAXCONNLIFETIME
MaxConnIdleTime: 5m # ZITADEL_DATABASE_COCKROACH_MAXCONNIDLETIME MaxConnIdleTime: 5m # ZITADEL_DATABASE_COCKROACH_MAXCONNIDLETIME
Options: "" # ZITADEL_DATABASE_COCKROACH_OPTIONS Options: "" # ZITADEL_DATABASE_COCKROACH_OPTIONS
@ -590,6 +579,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
@ -608,6 +602,9 @@ Console:
# 168h is 7 days, one week # 168h is 7 days, one week
SharedMaxAge: 168h # ZITADEL_CONSOLE_LONGCACHE_SHAREDMAXAGE SharedMaxAge: 168h # ZITADEL_CONSOLE_LONGCACHE_SHAREDMAXAGE
InstanceManagementURL: "" # ZITADEL_CONSOLE_INSTANCEMANAGEMENTURL InstanceManagementURL: "" # ZITADEL_CONSOLE_INSTANCEMANAGEMENTURL
PostHog:
URL: "" # ZITADEL_CONSOLE_POSTHOG_URL
Token: "" # ZITADEL_CONSOLE_POSTHOG_TOKEN
EncryptionKeys: EncryptionKeys:
DomainVerification: DomainVerification:
@ -1124,6 +1121,7 @@ DefaultInstance:
LoginDefaultOrg: true # ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINDEFAULTORG LoginDefaultOrg: true # ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINDEFAULTORG
# TriggerIntrospectionProjections: false # ZITADEL_DEFAULTINSTANCE_FEATURES_TRIGGERINTROSPECTIONPROJECTIONS # TriggerIntrospectionProjections: false # ZITADEL_DEFAULTINSTANCE_FEATURES_TRIGGERINTROSPECTIONPROJECTIONS
# LegacyIntrospection: false # ZITADEL_DEFAULTINSTANCE_FEATURES_LEGACYINTROSPECTION # LegacyIntrospection: false # ZITADEL_DEFAULTINSTANCE_FEATURES_LEGACYINTROSPECTION
# PermissionCheckV2: false # ZITADEL_DEFAULTINSTANCE_FEATURES_PERMISSIONCHECKV2
Limits: Limits:
# AuditLogRetention limits the number of events that can be queried via the events API by their age. # AuditLogRetention limits the number of events that can be queried via the events API by their age.
# A value of "0s" means that all events are available. # A value of "0s" means that all events are available.
@ -1187,6 +1185,9 @@ InternalAuthZ:
# Configure the RolePermissionMappings by environment variable using JSON notation: # Configure the RolePermissionMappings by environment variable using JSON notation:
# ZITADEL_INTERNALAUTHZ_ROLEPERMISSIONMAPPINGS='[{"role": "IAM_OWNER", "permissions": ["iam.write"]}, {"role": "ORG_OWNER", "permissions": ["org.write"]}]' # ZITADEL_INTERNALAUTHZ_ROLEPERMISSIONMAPPINGS='[{"role": "IAM_OWNER", "permissions": ["iam.write"]}, {"role": "ORG_OWNER", "permissions": ["org.write"]}]'
# Beware that if you configure the RolePermissionMappings by environment variable, all the default RolePermissionMappings are lost. # Beware that if you configure the RolePermissionMappings by environment variable, all the default RolePermissionMappings are lost.
#
# Warning: RolePermissionMappings are synhronized to the database.
# Changes here will only be applied after running `zitadel setup` or `zitadel start-from-setup`.
RolePermissionMappings: RolePermissionMappings:
- Role: "SYSTEM_OWNER" - Role: "SYSTEM_OWNER"
Permissions: Permissions:

View File

@ -9,7 +9,6 @@ import (
"github.com/zitadel/logging" "github.com/zitadel/logging"
"github.com/zitadel/zitadel/internal/database" "github.com/zitadel/zitadel/internal/database"
"github.com/zitadel/zitadel/internal/database/dialect"
) )
var ( var (
@ -79,7 +78,7 @@ func initialise(ctx context.Context, config database.Config, steps ...func(conte
return err return err
} }
db, err := database.Connect(config, true, dialect.DBPurposeQuery) db, err := database.Connect(config, true)
if err != nil { if err != nil {
return err return err
} }

View File

@ -11,7 +11,6 @@ import (
"github.com/zitadel/logging" "github.com/zitadel/logging"
"github.com/zitadel/zitadel/internal/database" "github.com/zitadel/zitadel/internal/database"
"github.com/zitadel/zitadel/internal/database/dialect"
es_v3 "github.com/zitadel/zitadel/internal/eventstore/v3" es_v3 "github.com/zitadel/zitadel/internal/eventstore/v3"
) )
@ -85,7 +84,7 @@ func VerifyZitadel(ctx context.Context, db *database.DB, config database.Config)
func verifyZitadel(ctx context.Context, config database.Config) error { func verifyZitadel(ctx context.Context, config database.Config) error {
logging.WithFields("database", config.DatabaseName()).Info("verify zitadel") logging.WithFields("database", config.DatabaseName()).Info("verify zitadel")
db, err := database.Connect(config, false, dialect.DBPurposeQuery) db, err := database.Connect(config, false)
if err != nil { if err != nil {
return err return err
} }

View File

@ -12,7 +12,6 @@ import (
"github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/crypto"
cryptoDB "github.com/zitadel/zitadel/internal/crypto/database" cryptoDB "github.com/zitadel/zitadel/internal/crypto/database"
"github.com/zitadel/zitadel/internal/database" "github.com/zitadel/zitadel/internal/database"
"github.com/zitadel/zitadel/internal/database/dialect"
"github.com/zitadel/zitadel/internal/zerrors" "github.com/zitadel/zitadel/internal/zerrors"
) )
@ -124,7 +123,7 @@ func openFile(fileName string) (io.Reader, error) {
} }
func keyStorage(config database.Config, masterKey string) (crypto.KeyStorage, error) { func keyStorage(config database.Config, masterKey string) (crypto.KeyStorage, error) {
db, err := database.Connect(config, false, dialect.DBPurposeQuery) db, err := database.Connect(config, false)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@ -12,7 +12,6 @@ import (
"github.com/zitadel/logging" "github.com/zitadel/logging"
"github.com/zitadel/zitadel/internal/database" "github.com/zitadel/zitadel/internal/database"
"github.com/zitadel/zitadel/internal/database/dialect"
) )
func authCmd() *cobra.Command { func authCmd() *cobra.Command {
@ -34,11 +33,11 @@ Only auth requests are mirrored`,
} }
func copyAuth(ctx context.Context, config *Migration) { func copyAuth(ctx context.Context, config *Migration) {
sourceClient, err := database.Connect(config.Source, false, dialect.DBPurposeQuery) sourceClient, err := database.Connect(config.Source, false)
logging.OnError(err).Fatal("unable to connect to source database") logging.OnError(err).Fatal("unable to connect to source database")
defer sourceClient.Close() defer sourceClient.Close()
destClient, err := database.Connect(config.Destination, false, dialect.DBPurposeEventPusher) destClient, err := database.Connect(config.Destination, false)
logging.OnError(err).Fatal("unable to connect to destination database") logging.OnError(err).Fatal("unable to connect to destination database")
defer destClient.Close() defer destClient.Close()

View File

@ -14,7 +14,6 @@ import (
"github.com/zitadel/logging" "github.com/zitadel/logging"
db "github.com/zitadel/zitadel/internal/database" db "github.com/zitadel/zitadel/internal/database"
"github.com/zitadel/zitadel/internal/database/dialect"
"github.com/zitadel/zitadel/internal/id" "github.com/zitadel/zitadel/internal/id"
"github.com/zitadel/zitadel/internal/v2/database" "github.com/zitadel/zitadel/internal/v2/database"
"github.com/zitadel/zitadel/internal/v2/eventstore" "github.com/zitadel/zitadel/internal/v2/eventstore"
@ -44,11 +43,11 @@ Migrate only copies events2 and unique constraints`,
} }
func copyEventstore(ctx context.Context, config *Migration) { func copyEventstore(ctx context.Context, config *Migration) {
sourceClient, err := db.Connect(config.Source, false, dialect.DBPurposeEventPusher) sourceClient, err := db.Connect(config.Source, false)
logging.OnError(err).Fatal("unable to connect to source database") logging.OnError(err).Fatal("unable to connect to source database")
defer sourceClient.Close() defer sourceClient.Close()
destClient, err := db.Connect(config.Destination, false, dialect.DBPurposeEventPusher) destClient, err := db.Connect(config.Destination, false)
logging.OnError(err).Fatal("unable to connect to destination database") logging.OnError(err).Fatal("unable to connect to destination database")
defer destClient.Close() defer destClient.Close()

View File

@ -30,7 +30,6 @@ import (
"github.com/zitadel/zitadel/internal/config/systemdefaults" "github.com/zitadel/zitadel/internal/config/systemdefaults"
crypto_db "github.com/zitadel/zitadel/internal/crypto/database" crypto_db "github.com/zitadel/zitadel/internal/crypto/database"
"github.com/zitadel/zitadel/internal/database" "github.com/zitadel/zitadel/internal/database"
"github.com/zitadel/zitadel/internal/database/dialect"
"github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/eventstore"
old_es "github.com/zitadel/zitadel/internal/eventstore/repository/sql" old_es "github.com/zitadel/zitadel/internal/eventstore/repository/sql"
@ -106,7 +105,7 @@ func projections(
) { ) {
start := time.Now() start := time.Now()
client, err := database.Connect(config.Destination, false, dialect.DBPurposeQuery) client, err := database.Connect(config.Destination, false)
logging.OnError(err).Fatal("unable to connect to database") logging.OnError(err).Fatal("unable to connect to database")
keyStorage, err := crypto_db.NewKeyStorage(client, masterKey) keyStorage, err := crypto_db.NewKeyStorage(client, masterKey)
@ -119,9 +118,7 @@ func projections(
logging.OnError(err).Fatal("unable create static storage") logging.OnError(err).Fatal("unable create static storage")
config.Eventstore.Querier = old_es.NewCRDB(client) config.Eventstore.Querier = old_es.NewCRDB(client)
esPusherDBClient, err := database.Connect(config.Destination, false, dialect.DBPurposeEventPusher) config.Eventstore.Pusher = new_es.NewEventstore(client)
logging.OnError(err).Fatal("unable to connect eventstore push client")
config.Eventstore.Pusher = new_es.NewEventstore(esPusherDBClient)
es := eventstore.NewEventstore(config.Eventstore) es := eventstore.NewEventstore(config.Eventstore)
esV4 := es_v4.NewEventstoreFromOne(es_v4_pg.New(client, &es_v4_pg.Config{ esV4 := es_v4.NewEventstoreFromOne(es_v4_pg.New(client, &es_v4_pg.Config{
MaxRetries: config.Eventstore.MaxRetries, MaxRetries: config.Eventstore.MaxRetries,

View File

@ -12,7 +12,6 @@ import (
"github.com/zitadel/logging" "github.com/zitadel/logging"
"github.com/zitadel/zitadel/internal/database" "github.com/zitadel/zitadel/internal/database"
"github.com/zitadel/zitadel/internal/database/dialect"
) )
func systemCmd() *cobra.Command { func systemCmd() *cobra.Command {
@ -34,11 +33,11 @@ Only keys and assets are mirrored`,
} }
func copySystem(ctx context.Context, config *Migration) { func copySystem(ctx context.Context, config *Migration) {
sourceClient, err := database.Connect(config.Source, false, dialect.DBPurposeQuery) sourceClient, err := database.Connect(config.Source, false)
logging.OnError(err).Fatal("unable to connect to source database") logging.OnError(err).Fatal("unable to connect to source database")
defer sourceClient.Close() defer sourceClient.Close()
destClient, err := database.Connect(config.Destination, false, dialect.DBPurposeEventPusher) destClient, err := database.Connect(config.Destination, false)
logging.OnError(err).Fatal("unable to connect to destination database") logging.OnError(err).Fatal("unable to connect to destination database")
defer destClient.Close() defer destClient.Close()

View File

@ -13,7 +13,6 @@ import (
cryptoDatabase "github.com/zitadel/zitadel/internal/crypto/database" cryptoDatabase "github.com/zitadel/zitadel/internal/crypto/database"
"github.com/zitadel/zitadel/internal/database" "github.com/zitadel/zitadel/internal/database"
"github.com/zitadel/zitadel/internal/database/dialect"
"github.com/zitadel/zitadel/internal/query/projection" "github.com/zitadel/zitadel/internal/query/projection"
) )
@ -37,11 +36,11 @@ var schemas = []string{
} }
func verifyMigration(ctx context.Context, config *Migration) { func verifyMigration(ctx context.Context, config *Migration) {
sourceClient, err := database.Connect(config.Source, false, dialect.DBPurposeQuery) sourceClient, err := database.Connect(config.Source, false)
logging.OnError(err).Fatal("unable to connect to source database") logging.OnError(err).Fatal("unable to connect to source database")
defer sourceClient.Close() defer sourceClient.Close()
destClient, err := database.Connect(config.Destination, false, dialect.DBPurposeEventPusher) destClient, err := database.Connect(config.Destination, false)
logging.OnError(err).Fatal("unable to connect to destination database") logging.OnError(err).Fatal("unable to connect to destination database")
defer destClient.Close() defer destClient.Close()

View File

@ -1,44 +0,0 @@
package setup
import (
"context"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/query/projection"
"github.com/zitadel/zitadel/internal/repository/instance"
)
type FillFieldsForInstanceDomains struct {
eventstore *eventstore.Eventstore
}
func (mig *FillFieldsForInstanceDomains) Execute(ctx context.Context, _ eventstore.Event) error {
instances, err := mig.eventstore.InstanceIDs(
ctx,
eventstore.NewSearchQueryBuilder(eventstore.ColumnsInstanceIDs).
OrderDesc().
AddQuery().
AggregateTypes("instance").
EventTypes(instance.InstanceAddedEventType).
Builder(),
)
if err != nil {
return err
}
for _, instance := range instances {
ctx := authz.WithInstanceID(ctx, instance)
if err := projection.InstanceDomainFields.Trigger(ctx); err != nil {
return err
}
}
return nil
}
func (mig *FillFieldsForInstanceDomains) String() string {
return "repeatable_fill_fields_for_instance_domains"
}
func (f *FillFieldsForInstanceDomains) Check(lastRun map[string]interface{}) bool {
return true
}

111
cmd/setup/45.go Normal file
View File

@ -0,0 +1,111 @@
package setup
import (
"context"
_ "embed"
"encoding/json"
"fmt"
"github.com/zitadel/logging"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/query/projection"
"github.com/zitadel/zitadel/internal/repository/instance"
"github.com/zitadel/zitadel/internal/repository/owner"
"github.com/zitadel/zitadel/internal/repository/project"
)
var (
//go:embed 45.sql
correctProjectOwnerEvents string
)
type CorrectProjectOwners struct {
eventstore *eventstore.Eventstore
}
func (mig *CorrectProjectOwners) Execute(ctx context.Context, _ eventstore.Event) error {
instances, err := mig.eventstore.InstanceIDs(
ctx,
eventstore.NewSearchQueryBuilder(eventstore.ColumnsInstanceIDs).
OrderDesc().
AddQuery().
AggregateTypes("instance").
EventTypes(instance.InstanceAddedEventType).
Builder(),
)
if err != nil {
return err
}
ctx = authz.SetCtxData(ctx, authz.CtxData{UserID: "SETUP"})
for i, instance := range instances {
ctx = authz.WithInstanceID(ctx, instance)
logging.WithFields("instance_id", instance, "migration", mig.String(), "progress", fmt.Sprintf("%d/%d", i+1, len(instances))).Info("correct owners of projects")
didCorrect, err := mig.correctInstanceProjects(ctx, instance)
if err != nil {
return err
}
if !didCorrect {
continue
}
_, err = projection.ProjectGrantProjection.Trigger(ctx)
logging.OnError(err).Debug("failed triggering project grant projection to update owners")
}
return nil
}
func (mig *CorrectProjectOwners) correctInstanceProjects(ctx context.Context, instance string) (didCorrect bool, err error) {
var correctedOwners []eventstore.Command
tx, err := mig.eventstore.Client().BeginTx(ctx, nil)
if err != nil {
return false, err
}
defer func() {
if err != nil {
_ = tx.Rollback()
return
}
err = tx.Commit()
}()
rows, err := tx.QueryContext(ctx, correctProjectOwnerEvents, instance)
if err != nil {
return false, err
}
defer rows.Close()
for rows.Next() {
aggregate := &eventstore.Aggregate{
InstanceID: instance,
Type: project.AggregateType,
Version: project.AggregateVersion,
}
var payload json.RawMessage
err := rows.Scan(
&aggregate.ID,
&aggregate.ResourceOwner,
&payload,
)
if err != nil {
return false, err
}
previousOwners := make(map[uint32]string)
if err := json.Unmarshal(payload, &previousOwners); err != nil {
return false, err
}
correctedOwners = append(correctedOwners, owner.NewCorrected(ctx, aggregate, previousOwners))
}
if rows.Err() != nil {
return false, rows.Err()
}
_, err = mig.eventstore.PushWithClient(ctx, tx, correctedOwners...)
return len(correctedOwners) > 0, err
}
func (*CorrectProjectOwners) String() string {
return "43_correct_project_owners"
}

79
cmd/setup/45.sql Normal file
View File

@ -0,0 +1,79 @@
WITH corrupt_streams AS (
select
e.instance_id
, e.aggregate_type
, e.aggregate_id
, min(e.sequence) as min_sequence
, count(distinct e.owner) as owner_count
from
eventstore.events2 e
where
e.instance_id = $1
and aggregate_type = 'project'
group by
e.instance_id
, e.aggregate_type
, e.aggregate_id
having
count(distinct e.owner) > 1
), correct_owners AS (
select
e.instance_id
, e.aggregate_type
, e.aggregate_id
, e.owner
from
eventstore.events2 e
join
corrupt_streams cs
on
e.instance_id = cs.instance_id
and e.aggregate_type = cs.aggregate_type
and e.aggregate_id = cs.aggregate_id
and e.sequence = cs.min_sequence
), wrong_events AS (
select
e.instance_id
, e.aggregate_type
, e.aggregate_id
, e.sequence
, e.owner wrong_owner
, co.owner correct_owner
from
eventstore.events2 e
join
correct_owners co
on
e.instance_id = co.instance_id
and e.aggregate_type = co.aggregate_type
and e.aggregate_id = co.aggregate_id
and e.owner <> co.owner
), updated_events AS (
UPDATE eventstore.events2 e
SET owner = we.correct_owner
FROM
wrong_events we
WHERE
e.instance_id = we.instance_id
and e.aggregate_type = we.aggregate_type
and e.aggregate_id = we.aggregate_id
and e.sequence = we.sequence
RETURNING
we.aggregate_id
, we.correct_owner
, we.sequence
, we.wrong_owner
)
SELECT
ue.aggregate_id
, ue.correct_owner
, jsonb_object_agg(
ue.sequence::TEXT --formant to string because crdb is not able to handle int
, ue.wrong_owner
) payload
FROM
updated_events ue
GROUP BY
ue.aggregate_id
, ue.correct_owner
;

39
cmd/setup/46.go Normal file
View File

@ -0,0 +1,39 @@
package setup
import (
"context"
"embed"
"fmt"
"github.com/zitadel/logging"
"github.com/zitadel/zitadel/internal/database"
"github.com/zitadel/zitadel/internal/eventstore"
)
type InitPermissionFunctions struct {
eventstoreClient *database.DB
}
var (
//go:embed 46/*.sql
permissionFunctions embed.FS
)
func (mig *InitPermissionFunctions) Execute(ctx context.Context, _ eventstore.Event) error {
statements, err := readStatements(permissionFunctions, "46", "")
if err != nil {
return err
}
for _, stmt := range statements {
logging.WithFields("file", stmt.file, "migration", mig.String()).Info("execute statement")
if _, err := mig.eventstoreClient.ExecContext(ctx, stmt.query); err != nil {
return fmt.Errorf("%s %s: %w", mig.String(), stmt.file, err)
}
}
return nil
}
func (*InitPermissionFunctions) String() string {
return "46_init_permission_functions"
}

View File

@ -0,0 +1,6 @@
CREATE OR REPLACE VIEW eventstore.role_permissions AS
SELECT instance_id, aggregate_id, object_id as role, text_value as permission
FROM eventstore.fields
WHERE aggregate_type = 'permission'
AND object_type = 'role_permission'
AND field_name = 'permission';

View File

@ -0,0 +1,6 @@
CREATE OR REPLACE VIEW eventstore.instance_orgs AS
SELECT instance_id, aggregate_id as org_id
FROM eventstore.fields
WHERE aggregate_type = 'org'
AND object_type = 'org'
AND field_name = 'state';

View File

@ -0,0 +1,6 @@
CREATE OR REPLACE VIEW eventstore.instance_members AS
SELECT instance_id, object_id as user_id, text_value as role
FROM eventstore.fields
WHERE aggregate_type = 'instance'
AND object_type = 'instance_member_role'
AND field_name = 'instance_role';

View File

@ -0,0 +1,6 @@
CREATE OR REPLACE VIEW eventstore.org_members AS
SELECT instance_id, aggregate_id as org_id, object_id as user_id, text_value as role
FROM eventstore.fields
WHERE aggregate_type = 'org'
AND object_type = 'org_member_role'
AND field_name = 'org_role';

View File

@ -0,0 +1,6 @@
CREATE OR REPLACE VIEW eventstore.project_members AS
SELECT instance_id, aggregate_id as project_id, object_id as user_id, text_value as role
FROM eventstore.fields
WHERE aggregate_type = 'project'
AND object_type = 'project_member_role'
AND field_name = 'project_role';

View File

@ -0,0 +1,50 @@
CREATE OR REPLACE FUNCTION eventstore.permitted_orgs(
instanceId TEXT
, userId TEXT
, perm TEXT
, org_ids OUT TEXT[]
)
LANGUAGE 'plpgsql'
STABLE
AS $$
DECLARE
matched_roles TEXT[]; -- roles containing permission
BEGIN
SELECT array_agg(rp.role) INTO matched_roles
FROM eventstore.role_permissions rp
WHERE rp.instance_id = instanceId
AND rp.permission = perm;
-- First try if the permission was granted thru an instance-level role
DECLARE
has_instance_permission bool;
BEGIN
SELECT true INTO has_instance_permission
FROM eventstore.instance_members im
WHERE im.role = ANY(matched_roles)
AND im.instance_id = instanceId
AND im.user_id = userId
LIMIT 1;
IF has_instance_permission THEN
-- Return all organizations
SELECT array_agg(o.org_id) INTO org_ids
FROM eventstore.instance_orgs o
WHERE o.instance_id = instanceId;
RETURN;
END IF;
END;
-- Return the organizations where permission were granted thru org-level roles
SELECT array_agg(org_id) INTO org_ids
FROM (
SELECT DISTINCT om.org_id
FROM eventstore.org_members om
WHERE om.role = ANY(matched_roles)
AND om.instance_id = instanceID
AND om.user_id = userId
) AS orgs;
RETURN;
END;
$$;

View File

@ -8,7 +8,6 @@ import (
"github.com/zitadel/logging" "github.com/zitadel/logging"
"github.com/zitadel/zitadel/internal/database" "github.com/zitadel/zitadel/internal/database"
"github.com/zitadel/zitadel/internal/database/dialect"
"github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/eventstore"
old_es "github.com/zitadel/zitadel/internal/eventstore/repository/sql" old_es "github.com/zitadel/zitadel/internal/eventstore/repository/sql"
new_es "github.com/zitadel/zitadel/internal/eventstore/v3" new_es "github.com/zitadel/zitadel/internal/eventstore/v3"
@ -32,13 +31,11 @@ func Cleanup(config *Config) {
logging.Info("cleanup started") logging.Info("cleanup started")
queryDBClient, err := database.Connect(config.Database, false, dialect.DBPurposeQuery) dbClient, err := database.Connect(config.Database, false)
logging.OnError(err).Fatal("unable to connect to database")
esPusherDBClient, err := database.Connect(config.Database, false, dialect.DBPurposeEventPusher)
logging.OnError(err).Fatal("unable to connect to database") logging.OnError(err).Fatal("unable to connect to database")
config.Eventstore.Pusher = new_es.NewEventstore(esPusherDBClient) config.Eventstore.Pusher = new_es.NewEventstore(dbClient)
config.Eventstore.Querier = old_es.NewCRDB(queryDBClient) config.Eventstore.Querier = old_es.NewCRDB(dbClient)
es := eventstore.NewEventstore(config.Eventstore) es := eventstore.NewEventstore(config.Eventstore)
step, err := migration.LastStuckStep(ctx, es) step, err := migration.LastStuckStep(ctx, es)

View File

@ -87,6 +87,9 @@ func MustNewConfig(v *viper.Viper) *Config {
id.Configure(config.Machine) id.Configure(config.Machine)
// Copy the global role permissions mappings to the instance until we allow instance-level configuration over the API.
config.DefaultInstance.RolePermissionMappings = config.InternalAuthZ.RolePermissionMappings
return config return config
} }
@ -130,6 +133,8 @@ type Steps struct {
s42Apps7OIDCConfigsLoginVersion *Apps7OIDCConfigsLoginVersion s42Apps7OIDCConfigsLoginVersion *Apps7OIDCConfigsLoginVersion
s43CreateFieldsDomainIndex *CreateFieldsDomainIndex s43CreateFieldsDomainIndex *CreateFieldsDomainIndex
s44ReplaceCurrentSequencesIndex *ReplaceCurrentSequencesIndex s44ReplaceCurrentSequencesIndex *ReplaceCurrentSequencesIndex
s45CorrectProjectOwners *CorrectProjectOwners
s46InitPermissionFunctions *InitPermissionFunctions
} }
func MustNewSteps(v *viper.Viper) *Steps { func MustNewSteps(v *viper.Viper) *Steps {

51
cmd/setup/fill_fields.go Normal file
View File

@ -0,0 +1,51 @@
package setup
import (
"context"
"fmt"
"github.com/zitadel/logging"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/eventstore/handler/v2"
"github.com/zitadel/zitadel/internal/repository/instance"
)
type RepeatableFillFields struct {
eventstore *eventstore.Eventstore
handlers []*handler.FieldHandler
}
func (mig *RepeatableFillFields) Execute(ctx context.Context, _ eventstore.Event) error {
instances, err := mig.eventstore.InstanceIDs(
ctx,
eventstore.NewSearchQueryBuilder(eventstore.ColumnsInstanceIDs).
OrderDesc().
AddQuery().
AggregateTypes(instance.AggregateType).
EventTypes(instance.InstanceAddedEventType).
Builder(),
)
if err != nil {
return err
}
for _, instance := range instances {
ctx := authz.WithInstanceID(ctx, instance)
for _, handler := range mig.handlers {
logging.WithFields("migration", mig.String(), "instance_id", instance, "handler", handler.String()).Info("run fields trigger")
if err := handler.Trigger(ctx); err != nil {
return fmt.Errorf("%s: %s: %w", mig.String(), handler.String(), err)
}
}
}
return nil
}
func (mig *RepeatableFillFields) String() string {
return "repeatable_fill_fields"
}
func (f *RepeatableFillFields) Check(lastRun map[string]interface{}) bool {
return true
}

View File

@ -26,9 +26,9 @@ import (
"github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/command"
cryptoDB "github.com/zitadel/zitadel/internal/crypto/database" cryptoDB "github.com/zitadel/zitadel/internal/crypto/database"
"github.com/zitadel/zitadel/internal/database" "github.com/zitadel/zitadel/internal/database"
"github.com/zitadel/zitadel/internal/database/dialect"
"github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/eventstore/handler/v2"
old_es "github.com/zitadel/zitadel/internal/eventstore/repository/sql" old_es "github.com/zitadel/zitadel/internal/eventstore/repository/sql"
new_es "github.com/zitadel/zitadel/internal/eventstore/v3" new_es "github.com/zitadel/zitadel/internal/eventstore/v3"
"github.com/zitadel/zitadel/internal/i18n" "github.com/zitadel/zitadel/internal/i18n"
@ -102,26 +102,22 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string)
i18n.MustLoadSupportedLanguagesFromDir() i18n.MustLoadSupportedLanguagesFromDir()
queryDBClient, err := database.Connect(config.Database, false, dialect.DBPurposeQuery) dbClient, err := database.Connect(config.Database, false)
logging.OnError(err).Fatal("unable to connect to database")
esPusherDBClient, err := database.Connect(config.Database, false, dialect.DBPurposeEventPusher)
logging.OnError(err).Fatal("unable to connect to database")
projectionDBClient, err := database.Connect(config.Database, false, dialect.DBPurposeProjectionSpooler)
logging.OnError(err).Fatal("unable to connect to database") logging.OnError(err).Fatal("unable to connect to database")
config.Eventstore.Querier = old_es.NewCRDB(queryDBClient) config.Eventstore.Querier = old_es.NewCRDB(dbClient)
esV3 := new_es.NewEventstore(esPusherDBClient) esV3 := new_es.NewEventstore(dbClient)
config.Eventstore.Pusher = esV3 config.Eventstore.Pusher = esV3
config.Eventstore.Searcher = esV3 config.Eventstore.Searcher = esV3
eventstoreClient := eventstore.NewEventstore(config.Eventstore) eventstoreClient := eventstore.NewEventstore(config.Eventstore)
logging.OnError(err).Fatal("unable to start eventstore") logging.OnError(err).Fatal("unable to start eventstore")
eventstoreV4 := es_v4.NewEventstoreFromOne(es_v4_pg.New(queryDBClient, &es_v4_pg.Config{ eventstoreV4 := es_v4.NewEventstoreFromOne(es_v4_pg.New(dbClient, &es_v4_pg.Config{
MaxRetries: config.Eventstore.MaxRetries, MaxRetries: config.Eventstore.MaxRetries,
})) }))
steps.s1ProjectionTable = &ProjectionTable{dbClient: queryDBClient.DB} steps.s1ProjectionTable = &ProjectionTable{dbClient: dbClient.DB}
steps.s2AssetsTable = &AssetTable{dbClient: queryDBClient.DB} steps.s2AssetsTable = &AssetTable{dbClient: dbClient.DB}
steps.FirstInstance.Skip = config.ForMirror || steps.FirstInstance.Skip steps.FirstInstance.Skip = config.ForMirror || steps.FirstInstance.Skip
steps.FirstInstance.instanceSetup = config.DefaultInstance steps.FirstInstance.instanceSetup = config.DefaultInstance
@ -129,7 +125,7 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string)
steps.FirstInstance.smtpEncryptionKey = config.EncryptionKeys.SMTP steps.FirstInstance.smtpEncryptionKey = config.EncryptionKeys.SMTP
steps.FirstInstance.oidcEncryptionKey = config.EncryptionKeys.OIDC steps.FirstInstance.oidcEncryptionKey = config.EncryptionKeys.OIDC
steps.FirstInstance.masterKey = masterKey steps.FirstInstance.masterKey = masterKey
steps.FirstInstance.db = queryDBClient steps.FirstInstance.db = dbClient
steps.FirstInstance.es = eventstoreClient steps.FirstInstance.es = eventstoreClient
steps.FirstInstance.defaults = config.SystemDefaults steps.FirstInstance.defaults = config.SystemDefaults
steps.FirstInstance.zitadelRoles = config.InternalAuthZ.RolePermissionMappings steps.FirstInstance.zitadelRoles = config.InternalAuthZ.RolePermissionMappings
@ -137,44 +133,46 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string)
steps.FirstInstance.externalSecure = config.ExternalSecure steps.FirstInstance.externalSecure = config.ExternalSecure
steps.FirstInstance.externalPort = config.ExternalPort steps.FirstInstance.externalPort = config.ExternalPort
steps.s5LastFailed = &LastFailed{dbClient: queryDBClient.DB} steps.s5LastFailed = &LastFailed{dbClient: dbClient.DB}
steps.s6OwnerRemoveColumns = &OwnerRemoveColumns{dbClient: queryDBClient.DB} steps.s6OwnerRemoveColumns = &OwnerRemoveColumns{dbClient: dbClient.DB}
steps.s7LogstoreTables = &LogstoreTables{dbClient: queryDBClient.DB, username: config.Database.Username(), dbType: config.Database.Type()} steps.s7LogstoreTables = &LogstoreTables{dbClient: dbClient.DB, username: config.Database.Username(), dbType: config.Database.Type()}
steps.s8AuthTokens = &AuthTokenIndexes{dbClient: queryDBClient} steps.s8AuthTokens = &AuthTokenIndexes{dbClient: dbClient}
steps.CorrectCreationDate.dbClient = esPusherDBClient steps.CorrectCreationDate.dbClient = dbClient
steps.s12AddOTPColumns = &AddOTPColumns{dbClient: queryDBClient} steps.s12AddOTPColumns = &AddOTPColumns{dbClient: dbClient}
steps.s13FixQuotaProjection = &FixQuotaConstraints{dbClient: queryDBClient} steps.s13FixQuotaProjection = &FixQuotaConstraints{dbClient: dbClient}
steps.s14NewEventsTable = &NewEventsTable{dbClient: esPusherDBClient} steps.s14NewEventsTable = &NewEventsTable{dbClient: dbClient}
steps.s15CurrentStates = &CurrentProjectionState{dbClient: queryDBClient} steps.s15CurrentStates = &CurrentProjectionState{dbClient: dbClient}
steps.s16UniqueConstraintsLower = &UniqueConstraintToLower{dbClient: queryDBClient} steps.s16UniqueConstraintsLower = &UniqueConstraintToLower{dbClient: dbClient}
steps.s17AddOffsetToUniqueConstraints = &AddOffsetToCurrentStates{dbClient: queryDBClient} steps.s17AddOffsetToUniqueConstraints = &AddOffsetToCurrentStates{dbClient: dbClient}
steps.s18AddLowerFieldsToLoginNames = &AddLowerFieldsToLoginNames{dbClient: queryDBClient} steps.s18AddLowerFieldsToLoginNames = &AddLowerFieldsToLoginNames{dbClient: dbClient}
steps.s19AddCurrentStatesIndex = &AddCurrentSequencesIndex{dbClient: queryDBClient} steps.s19AddCurrentStatesIndex = &AddCurrentSequencesIndex{dbClient: dbClient}
steps.s20AddByUserSessionIndex = &AddByUserIndexToSession{dbClient: queryDBClient} steps.s20AddByUserSessionIndex = &AddByUserIndexToSession{dbClient: dbClient}
steps.s21AddBlockFieldToLimits = &AddBlockFieldToLimits{dbClient: queryDBClient} steps.s21AddBlockFieldToLimits = &AddBlockFieldToLimits{dbClient: dbClient}
steps.s22ActiveInstancesIndex = &ActiveInstanceEvents{dbClient: queryDBClient} steps.s22ActiveInstancesIndex = &ActiveInstanceEvents{dbClient: dbClient}
steps.s23CorrectGlobalUniqueConstraints = &CorrectGlobalUniqueConstraints{dbClient: esPusherDBClient} steps.s23CorrectGlobalUniqueConstraints = &CorrectGlobalUniqueConstraints{dbClient: dbClient}
steps.s24AddActorToAuthTokens = &AddActorToAuthTokens{dbClient: queryDBClient} steps.s24AddActorToAuthTokens = &AddActorToAuthTokens{dbClient: dbClient}
steps.s25User11AddLowerFieldsToVerifiedEmail = &User11AddLowerFieldsToVerifiedEmail{dbClient: esPusherDBClient} steps.s25User11AddLowerFieldsToVerifiedEmail = &User11AddLowerFieldsToVerifiedEmail{dbClient: dbClient}
steps.s26AuthUsers3 = &AuthUsers3{dbClient: esPusherDBClient} steps.s26AuthUsers3 = &AuthUsers3{dbClient: dbClient}
steps.s27IDPTemplate6SAMLNameIDFormat = &IDPTemplate6SAMLNameIDFormat{dbClient: esPusherDBClient} steps.s27IDPTemplate6SAMLNameIDFormat = &IDPTemplate6SAMLNameIDFormat{dbClient: dbClient}
steps.s28AddFieldTable = &AddFieldTable{dbClient: esPusherDBClient} steps.s28AddFieldTable = &AddFieldTable{dbClient: dbClient}
steps.s29FillFieldsForProjectGrant = &FillFieldsForProjectGrant{eventstore: eventstoreClient} steps.s29FillFieldsForProjectGrant = &FillFieldsForProjectGrant{eventstore: eventstoreClient}
steps.s30FillFieldsForOrgDomainVerified = &FillFieldsForOrgDomainVerified{eventstore: eventstoreClient} steps.s30FillFieldsForOrgDomainVerified = &FillFieldsForOrgDomainVerified{eventstore: eventstoreClient}
steps.s31AddAggregateIndexToFields = &AddAggregateIndexToFields{dbClient: esPusherDBClient} steps.s31AddAggregateIndexToFields = &AddAggregateIndexToFields{dbClient: dbClient}
steps.s32AddAuthSessionID = &AddAuthSessionID{dbClient: esPusherDBClient} steps.s32AddAuthSessionID = &AddAuthSessionID{dbClient: dbClient}
steps.s33SMSConfigs3TwilioAddVerifyServiceSid = &SMSConfigs3TwilioAddVerifyServiceSid{dbClient: esPusherDBClient} steps.s33SMSConfigs3TwilioAddVerifyServiceSid = &SMSConfigs3TwilioAddVerifyServiceSid{dbClient: dbClient}
steps.s34AddCacheSchema = &AddCacheSchema{dbClient: queryDBClient} steps.s34AddCacheSchema = &AddCacheSchema{dbClient: dbClient}
steps.s35AddPositionToIndexEsWm = &AddPositionToIndexEsWm{dbClient: esPusherDBClient} steps.s35AddPositionToIndexEsWm = &AddPositionToIndexEsWm{dbClient: dbClient}
steps.s36FillV2Milestones = &FillV3Milestones{dbClient: queryDBClient, eventstore: eventstoreClient} steps.s36FillV2Milestones = &FillV3Milestones{dbClient: dbClient, eventstore: eventstoreClient}
steps.s37Apps7OIDConfigsBackChannelLogoutURI = &Apps7OIDConfigsBackChannelLogoutURI{dbClient: esPusherDBClient} steps.s37Apps7OIDConfigsBackChannelLogoutURI = &Apps7OIDConfigsBackChannelLogoutURI{dbClient: dbClient}
steps.s38BackChannelLogoutNotificationStart = &BackChannelLogoutNotificationStart{dbClient: esPusherDBClient, esClient: eventstoreClient} steps.s38BackChannelLogoutNotificationStart = &BackChannelLogoutNotificationStart{dbClient: dbClient, esClient: eventstoreClient}
steps.s40InitPushFunc = &InitPushFunc{dbClient: esPusherDBClient} steps.s40InitPushFunc = &InitPushFunc{dbClient: dbClient}
steps.s42Apps7OIDCConfigsLoginVersion = &Apps7OIDCConfigsLoginVersion{dbClient: esPusherDBClient} steps.s42Apps7OIDCConfigsLoginVersion = &Apps7OIDCConfigsLoginVersion{dbClient: dbClient}
steps.s43CreateFieldsDomainIndex = &CreateFieldsDomainIndex{dbClient: queryDBClient} steps.s43CreateFieldsDomainIndex = &CreateFieldsDomainIndex{dbClient: dbClient}
steps.s44ReplaceCurrentSequencesIndex = &ReplaceCurrentSequencesIndex{dbClient: esPusherDBClient} steps.s44ReplaceCurrentSequencesIndex = &ReplaceCurrentSequencesIndex{dbClient: dbClient}
steps.s45CorrectProjectOwners = &CorrectProjectOwners{eventstore: eventstoreClient}
steps.s46InitPermissionFunctions = &InitPermissionFunctions{eventstoreClient: dbClient}
err = projection.Create(ctx, projectionDBClient, eventstoreClient, config.Projections, nil, nil, nil) err = projection.Create(ctx, dbClient, eventstoreClient, config.Projections, nil, nil, nil)
logging.OnError(err).Fatal("unable to start projections") logging.OnError(err).Fatal("unable to start projections")
repeatableSteps := []migration.RepeatableMigration{ repeatableSteps := []migration.RepeatableMigration{
@ -192,8 +190,16 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string)
&DeleteStaleOrgFields{ &DeleteStaleOrgFields{
eventstore: eventstoreClient, eventstore: eventstoreClient,
}, },
&FillFieldsForInstanceDomains{ &RepeatableFillFields{
eventstore: eventstoreClient, eventstore: eventstoreClient,
handlers: []*handler.FieldHandler{
projection.InstanceDomainFields,
projection.MembershipFields,
},
},
&SyncRolePermissions{
eventstore: eventstoreClient,
rolePermissionMappings: config.InternalAuthZ.RolePermissionMappings,
}, },
} }
@ -227,6 +233,8 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string)
steps.s36FillV2Milestones, steps.s36FillV2Milestones,
steps.s38BackChannelLogoutNotificationStart, steps.s38BackChannelLogoutNotificationStart,
steps.s44ReplaceCurrentSequencesIndex, steps.s44ReplaceCurrentSequencesIndex,
steps.s45CorrectProjectOwners,
steps.s46InitPermissionFunctions,
} { } {
mustExecuteMigration(ctx, eventstoreClient, step, "migration failed") mustExecuteMigration(ctx, eventstoreClient, step, "migration failed")
} }
@ -256,8 +264,8 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string)
ctx, ctx,
eventstoreClient, eventstoreClient,
eventstoreV4, eventstoreV4,
queryDBClient, dbClient,
projectionDBClient, dbClient,
masterKey, masterKey,
config, config,
) )

View File

@ -0,0 +1,134 @@
package setup
import (
"context"
"database/sql"
_ "embed"
"fmt"
"strings"
"github.com/zitadel/logging"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/database"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/repository/instance"
"github.com/zitadel/zitadel/internal/repository/permission"
)
var (
//go:embed sync_role_permissions.sql
getRolePermissionOperationsQuery string
)
// SyncRolePermissions is a repeatable step which synchronizes the InternalAuthZ
// RolePermissionMappings from the configuration to the database.
// This is needed until role permissions are manageable over the API.
type SyncRolePermissions struct {
eventstore *eventstore.Eventstore
rolePermissionMappings []authz.RoleMapping
}
func (mig *SyncRolePermissions) Execute(ctx context.Context, _ eventstore.Event) error {
if err := mig.executeSystem(ctx); err != nil {
return err
}
return mig.executeInstances(ctx)
}
func (mig *SyncRolePermissions) executeSystem(ctx context.Context) error {
logging.WithFields("migration", mig.String()).Info("prepare system role permission sync events")
target := rolePermissionMappingsToDatabaseMap(mig.rolePermissionMappings, true)
cmds, err := mig.synchronizeCommands(ctx, "SYSTEM", target)
if err != nil {
return err
}
events, err := mig.eventstore.Push(ctx, cmds...)
if err != nil {
return err
}
logging.WithFields("migration", mig.String(), "pushed_events", len(events)).Info("pushed system role permission sync events")
return nil
}
func (mig *SyncRolePermissions) executeInstances(ctx context.Context) error {
instances, err := mig.eventstore.InstanceIDs(
ctx,
eventstore.NewSearchQueryBuilder(eventstore.ColumnsInstanceIDs).
OrderDesc().
AddQuery().
AggregateTypes(instance.AggregateType).
EventTypes(instance.InstanceAddedEventType).
Builder().
ExcludeAggregateIDs().
AggregateTypes(instance.AggregateType).
EventTypes(instance.InstanceRemovedEventType).
Builder(),
)
if err != nil {
return err
}
target := rolePermissionMappingsToDatabaseMap(mig.rolePermissionMappings, false)
for i, instanceID := range instances {
logging.WithFields("instance_id", instanceID, "migration", mig.String(), "progress", fmt.Sprintf("%d/%d", i+1, len(instances))).Info("prepare instance role permission sync events")
cmds, err := mig.synchronizeCommands(ctx, instanceID, target)
if err != nil {
return err
}
events, err := mig.eventstore.Push(ctx, cmds...)
if err != nil {
return err
}
logging.WithFields("instance_id", instanceID, "migration", mig.String(), "pushed_events", len(events)).Info("pushed instance role permission sync events")
}
return nil
}
// synchronizeCommands checks the current state of role permissions in the eventstore for the aggregate.
// It returns the commands required to reach the desired state passed in target.
// For system level permissions aggregateID must be set to `SYSTEM`,
// else it is the instance ID.
func (mig *SyncRolePermissions) synchronizeCommands(ctx context.Context, aggregateID string, target database.Map[[]string]) (cmds []eventstore.Command, err error) {
aggregate := permission.NewAggregate(aggregateID)
err = mig.eventstore.Client().QueryContext(ctx, func(rows *sql.Rows) error {
for rows.Next() {
var operation, role, perm string
if err := rows.Scan(&operation, &role, &perm); err != nil {
return err
}
logging.WithFields("aggregate_id", aggregateID, "migration", mig.String(), "operation", operation, "role", role, "permission", perm).Debug("sync role permission")
switch operation {
case "add":
cmds = append(cmds, permission.NewAddedEvent(ctx, aggregate, role, perm))
case "remove":
cmds = append(cmds, permission.NewRemovedEvent(ctx, aggregate, role, perm))
}
}
return rows.Close()
}, getRolePermissionOperationsQuery, aggregateID, target)
if err != nil {
return nil, err
}
return cmds, err
}
func (*SyncRolePermissions) String() string {
return "repeatable_sync_role_permissions"
}
func (*SyncRolePermissions) Check(lastRun map[string]interface{}) bool {
return true
}
func rolePermissionMappingsToDatabaseMap(mappings []authz.RoleMapping, system bool) database.Map[[]string] {
out := make(database.Map[[]string], len(mappings))
for _, m := range mappings {
if system == strings.HasPrefix(m.Role, "SYSTEM") {
out[m.Role] = m.Permissions
}
}
return out
}

View File

@ -0,0 +1,52 @@
/*
This query creates a change set of permissions that need to be added or removed.
It compares the current state in the fields table (thru the role_permissions view)
against a passed role permission mapping as JSON, created from Zitadel's config:
{
"IAM_ADMIN_IMPERSONATOR": ["admin.impersonation", "impersonation"],
"IAM_END_USER_IMPERSONATOR": ["impersonation"],
"FOO_BAR": ["foo.bar", "bar.foo"]
}
It uses an aggregate_id as first argument which may be an instance_id or 'SYSTEM'
for system level permissions.
*/
WITH target AS (
-- unmarshal JSON representation into flattened tabular data
SELECT
key AS role,
jsonb_array_elements_text(value) AS permission
FROM jsonb_each($2::jsonb)
), add AS (
-- find all role permissions that exist in `target` and not in `role_permissions`
SELECT t.role, t.permission
FROM eventstore.role_permissions p
RIGHT JOIN target t
ON p.aggregate_id = $1::text
AND p.role = t.role
AND p.permission = t.permission
WHERE p.role IS NULL
), remove AS (
-- find all role permissions that exist `role_permissions` and not in `target`
SELECT p.role, p.permission
FROM eventstore.role_permissions p
LEFT JOIN target t
ON p.role = t.role
AND p.permission = t.permission
WHERE p.aggregate_id = $1::text
AND t.role IS NULL
)
-- return the required operations
SELECT
'add' AS operation,
role,
permission
FROM add
UNION ALL
SELECT
'remove' AS operation,
role,
permission
FROM remove
;

View File

@ -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
@ -125,5 +127,8 @@ func MustNewConfig(v *viper.Viper) *Config {
id.Configure(config.Machine) id.Configure(config.Machine)
actions.SetHTTPConfig(&config.Actions.HTTP) actions.SetHTTPConfig(&config.Actions.HTTP)
// Copy the global role permissions mappings to the instance until we allow instance-level configuration over the API.
config.DefaultInstance.RolePermissionMappings = config.InternalAuthZ.RolePermissionMappings
return config return config
} }

View File

@ -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"
@ -75,7 +77,6 @@ import (
"github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/crypto"
cryptoDB "github.com/zitadel/zitadel/internal/crypto/database" cryptoDB "github.com/zitadel/zitadel/internal/crypto/database"
"github.com/zitadel/zitadel/internal/database" "github.com/zitadel/zitadel/internal/database"
"github.com/zitadel/zitadel/internal/database/dialect"
"github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/eventstore"
old_es "github.com/zitadel/zitadel/internal/eventstore/repository/sql" old_es "github.com/zitadel/zitadel/internal/eventstore/repository/sql"
@ -148,20 +149,12 @@ func startZitadel(ctx context.Context, config *Config, masterKey string, server
i18n.MustLoadSupportedLanguagesFromDir() i18n.MustLoadSupportedLanguagesFromDir()
queryDBClient, err := database.Connect(config.Database, false, dialect.DBPurposeQuery) dbClient, err := database.Connect(config.Database, false)
if err != nil { if err != nil {
return fmt.Errorf("cannot start DB client for queries: %w", err) return fmt.Errorf("cannot start DB client for queries: %w", err)
} }
esPusherDBClient, err := database.Connect(config.Database, false, dialect.DBPurposeEventPusher)
if err != nil {
return fmt.Errorf("cannot start client for event store pusher: %w", err)
}
projectionDBClient, err := database.Connect(config.Database, false, dialect.DBPurposeProjectionSpooler)
if err != nil {
return fmt.Errorf("cannot start client for projection spooler: %w", err)
}
keyStorage, err := cryptoDB.NewKeyStorage(queryDBClient, masterKey) keyStorage, err := cryptoDB.NewKeyStorage(dbClient, masterKey)
if err != nil { if err != nil {
return fmt.Errorf("cannot start key storage: %w", err) return fmt.Errorf("cannot start key storage: %w", err)
} }
@ -170,16 +163,16 @@ func startZitadel(ctx context.Context, config *Config, masterKey string, server
return err return err
} }
config.Eventstore.Pusher = new_es.NewEventstore(esPusherDBClient) config.Eventstore.Pusher = new_es.NewEventstore(dbClient)
config.Eventstore.Searcher = new_es.NewEventstore(queryDBClient) config.Eventstore.Searcher = new_es.NewEventstore(dbClient)
config.Eventstore.Querier = old_es.NewCRDB(queryDBClient) config.Eventstore.Querier = old_es.NewCRDB(dbClient)
eventstoreClient := eventstore.NewEventstore(config.Eventstore) eventstoreClient := eventstore.NewEventstore(config.Eventstore)
eventstoreV4 := es_v4.NewEventstoreFromOne(es_v4_pg.New(queryDBClient, &es_v4_pg.Config{ eventstoreV4 := es_v4.NewEventstoreFromOne(es_v4_pg.New(dbClient, &es_v4_pg.Config{
MaxRetries: config.Eventstore.MaxRetries, MaxRetries: config.Eventstore.MaxRetries,
})) }))
sessionTokenVerifier := internal_authz.SessionTokenVerifier(keys.OIDC) sessionTokenVerifier := internal_authz.SessionTokenVerifier(keys.OIDC)
cacheConnectors, err := connector.StartConnectors(config.Caches, queryDBClient) cacheConnectors, err := connector.StartConnectors(config.Caches, dbClient)
if err != nil { if err != nil {
return fmt.Errorf("unable to start caches: %w", err) return fmt.Errorf("unable to start caches: %w", err)
} }
@ -188,8 +181,8 @@ func startZitadel(ctx context.Context, config *Config, masterKey string, server
ctx, ctx,
eventstoreClient, eventstoreClient,
eventstoreV4.Querier, eventstoreV4.Querier,
queryDBClient, dbClient,
projectionDBClient, dbClient,
cacheConnectors, cacheConnectors,
config.Projections, config.Projections,
config.SystemDefaults, config.SystemDefaults,
@ -213,7 +206,7 @@ func startZitadel(ctx context.Context, config *Config, masterKey string, server
return fmt.Errorf("cannot start queries: %w", err) return fmt.Errorf("cannot start queries: %w", err)
} }
authZRepo, err := authz.Start(queries, eventstoreClient, queryDBClient, keys.OIDC, config.ExternalSecure) authZRepo, err := authz.Start(queries, eventstoreClient, dbClient, keys.OIDC, config.ExternalSecure)
if err != nil { if err != nil {
return fmt.Errorf("error starting authz repo: %w", err) return fmt.Errorf("error starting authz repo: %w", err)
} }
@ -221,7 +214,7 @@ func startZitadel(ctx context.Context, config *Config, masterKey string, server
return internal_authz.CheckPermission(ctx, authZRepo, config.InternalAuthZ.RolePermissionMappings, permission, orgID, resourceID) return internal_authz.CheckPermission(ctx, authZRepo, config.InternalAuthZ.RolePermissionMappings, permission, orgID, resourceID)
} }
storage, err := config.AssetStorage.NewStorage(queryDBClient.DB) storage, err := config.AssetStorage.NewStorage(dbClient.DB)
if err != nil { if err != nil {
return fmt.Errorf("cannot start asset storage client: %w", err) return fmt.Errorf("cannot start asset storage client: %w", err)
} }
@ -266,7 +259,7 @@ func startZitadel(ctx context.Context, config *Config, masterKey string, server
if err != nil { if err != nil {
return err return err
} }
actionsExecutionDBEmitter, err := logstore.NewEmitter[*record.ExecutionLog](ctx, clock, config.Quotas.Execution, execution.NewDatabaseLogStorage(queryDBClient, commands, queries)) actionsExecutionDBEmitter, err := logstore.NewEmitter[*record.ExecutionLog](ctx, clock, config.Quotas.Execution, execution.NewDatabaseLogStorage(dbClient, commands, queries))
if err != nil { if err != nil {
return err return err
} }
@ -295,7 +288,7 @@ func startZitadel(ctx context.Context, config *Config, masterKey string, server
keys.SMS, keys.SMS,
keys.OIDC, keys.OIDC,
config.OIDC.DefaultBackChannelLogoutLifetime, config.OIDC.DefaultBackChannelLogoutLifetime,
queryDBClient, dbClient,
) )
notification.Start(ctx) notification.Start(ctx)
@ -311,7 +304,7 @@ func startZitadel(ctx context.Context, config *Config, masterKey string, server
commands, commands,
queries, queries,
eventstoreClient, eventstoreClient,
queryDBClient, dbClient,
config, config,
storage, storage,
authZRepo, authZRepo,
@ -331,7 +324,7 @@ func startZitadel(ctx context.Context, config *Config, masterKey string, server
if server != nil { if server != nil {
server <- &Server{ server <- &Server{
Config: config, Config: config,
DB: queryDBClient, DB: dbClient,
KeyStorage: keyStorage, KeyStorage: keyStorage,
Keys: keys, Keys: keys,
Eventstore: eventstoreClient, Eventstore: eventstoreClient,
@ -440,7 +433,7 @@ func startAPIs(
if err := apis.RegisterService(ctx, user_v2.CreateServer(commands, queries, keys.User, keys.IDPConfig, idp.CallbackURL(), idp.SAMLRootURL(), assets.AssetAPI(), permissionCheck)); err != nil { if err := apis.RegisterService(ctx, user_v2.CreateServer(commands, queries, keys.User, keys.IDPConfig, idp.CallbackURL(), idp.SAMLRootURL(), assets.AssetAPI(), permissionCheck)); err != nil {
return nil, err return nil, err
} }
if err := apis.RegisterService(ctx, session_v2beta.CreateServer(commands, queries)); err != nil { if err := apis.RegisterService(ctx, session_v2beta.CreateServer(commands, queries, permissionCheck)); err != nil {
return nil, err return nil, err
} }
if err := apis.RegisterService(ctx, settings_v2beta.CreateServer(commands, queries)); err != nil { if err := apis.RegisterService(ctx, settings_v2beta.CreateServer(commands, queries)); err != nil {
@ -452,7 +445,7 @@ func startAPIs(
if err := apis.RegisterService(ctx, feature_v2beta.CreateServer(commands, queries)); err != nil { if err := apis.RegisterService(ctx, feature_v2beta.CreateServer(commands, queries)); err != nil {
return nil, err return nil, err
} }
if err := apis.RegisterService(ctx, session_v2.CreateServer(commands, queries)); err != nil { if err := apis.RegisterService(ctx, session_v2.CreateServer(commands, queries, permissionCheck)); err != nil {
return nil, err return nil, err
} }
if err := apis.RegisterService(ctx, settings_v2.CreateServer(commands, queries)); err != nil { if err := apis.RegisterService(ctx, settings_v2.CreateServer(commands, queries)); err != nil {
@ -519,6 +512,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)

View File

@ -26,7 +26,7 @@ Please check below the matrix for an overview where which scope is asserted.
| jti | No | Yes | No | When JWT | | jti | No | Yes | No | When JWT |
| locale | When requested | When requested | When requested and response_type `id_token` | No | | locale | When requested | When requested | When requested and response_type `id_token` | No |
| name | When requested | When requested | When requested and response_type `id_token` | No | | name | When requested | When requested | When requested and response_type `id_token` | No |
| nbf | No | Yes | Yes | When JWT | | nbf | No | Yes | No | When JWT |
| nonce | No | No | When provided in the authorization request [^1] | No | | nonce | No | No | When provided in the authorization request [^1] | No |
| phone | When requested | When requested | When requested and response_type `id_token` | No | | phone | When requested | When requested | When requested and response_type `id_token` | No |
| phone_verified | When requested | When requested | When requested and response_type `id_token` | No | | phone_verified | When requested | When requested | When requested and response_type `id_token` | No |

View File

@ -152,7 +152,7 @@ curl --request POST \
If you didn't get a user ID in the parameters of your success page, you know that there is no existing user in ZITADEL with that provider, and you can register a new user or link it to an existing account (read the next section). If you didn't get a user ID in the parameters of your success page, you know that there is no existing user in ZITADEL with that provider, and you can register a new user or link it to an existing account (read the next section).
Fill the IdP links in the create user request to add a user with an external login provider. Fill the IdP links in the create user request to add a user with an external login provider.
The idpId is the ID of the provider in ZITADEL, the idpExternalId is the ID of the user in the external identity provider; usually, this is sent in the “sub”. The idpId is the ID of the provider in ZITADEL, the userId is the ID of the user in the external identity provider; usually, this is sent in the “sub”.
The display name is used to list the linkings on the users. The display name is used to list the linkings on the users.
[Create User API Documentation](/docs/apis/resources/user_service_v2/user-service-add-human-user) [Create User API Documentation](/docs/apis/resources/user_service_v2/user-service-add-human-user)
@ -181,8 +181,8 @@ curl --request POST \
"idpLinks": [ "idpLinks": [
{ {
"idpId": "218528353504723201", "idpId": "218528353504723201",
"idpExternalId": "111392805975715856637", "userId": "111392805975715856637",
"displayName": "Minnie Mouse" "userName": "Minnie Mouse"
} }
] ]
}' }'
@ -205,8 +205,8 @@ curl --request POST \
--data '{ --data '{
"idpLink": { "idpLink": {
"idpId": "218528353504723201", "idpId": "218528353504723201",
"idpExternalId": "1113928059757158566371", "userId": "1113928059757158566371",
"displayName": "Minnie Mouse" "userName": "Minnie Mouse"
} }
}' }'
``` ```

View File

@ -146,4 +146,6 @@ Then create a personal access token (PAT), copy and set it as `ZITADEL_SERVICE_U
Finally set your instance url as `ZITADEL_API_URL`. Make sure to set it without trailing slash. Finally set your instance url as `ZITADEL_API_URL`. Make sure to set it without trailing slash.
Also ensure your login domain is registered on your instance by adding it as a [trusted domain](/docs/apis/resources/admin/admin-service-add-instance-trusted-domain). Also ensure your login domain is registered on your instance by adding it as a [trusted domain](/docs/apis/resources/admin/admin-service-add-instance-trusted-domain).
If you want to enforce users to have their email verified, you can set the optional `EMAIL_VERIFICATION` variable to `true` in your environment and your users will be enforced to verify their email address before they can log in.
![Deploy to Vercel](/img/deploy-to-vercel.png) ![Deploy to Vercel](/img/deploy-to-vercel.png)

View File

@ -0,0 +1,207 @@
---
title: Login users into your application with a hosted login UI
sidebar_label: Hosted Login UI
---
ZITADEL provides a hosted single-sign-on page to securely sign-in users to your applications.
ZITADEL's hosted login page serves as a centralized authentication interface provided for applications that integrate ZITADEL.
As a developer, understanding the hosted login page is essential for seamlessly integrating authentication into your application.
## Centralized authentication endpoint
ZITADEL's hosted login page acts as a centralized authentication endpoint where users are redirected to authenticate themselves.
When users attempt to access a protected resource within your application, you can redirect them to the hosted login page to authenticate using their login methods and credentials or through Single-sign-on (SSO).
After successful authentication, the user will be redirected back to the originating application.
## Security and compliance
ZITADEL's hosted login page prioritizes security and compliance with industry standards and regulations.
It employs best practices for securing authentication processes, such as encryption, token-based authentication, and adherence to protocols like OAuth 2.0, [OpenID Connect](/docs/guides/integrate/login/oidc), and [SAML](/docs/guides/integrate/login/).
We make sure to harden the login UI and minimize the attack surface.
One of the measures we apply is setting the necessary security heads thus minimizing the risk of common vulnerabilities in login pages, such as XSS vulnerabilities.
Put your current login to the test and compare the results with our hosted login page.
Tools like [Mozilla's Observatory](https://observatory.mozilla.org/) can give you a good first impression about the security posture.
## Developer-friendly integration
Integrating the hosted login page into your application is straightforward, thanks to ZITADEL's developer-friendly documentation, SDKs, and APIs. Developers can easily implement authentication flows, handle authentication callbacks, and customize the user experience to seamlessly integrate authentication with their application's workflow.
Overall, ZITADEL's hosted login page simplifies the authentication process for developers by providing a secure, customizable, and developer-friendly authentication interface. By leveraging this centralized authentication endpoint, developers can enhance their application's security, user experience, and compliance with industry standards and regulations.
## Key features of the hosted login
### Flexible usernames
Different login name formats can be used on ZITADEL's hosted login page to select a user.
Login methods can be a user's username, containing the username and an [organization domain](/docs/guides/manage/console/organizations#domain-verification-and-primary-domain), their email addresses, or their phone numbers.
By default, all of these login methods are allowed and can be adjusted by [Managers](/docs/concepts/structure/managers) to meet their requirements.
### Support for multiple authentication methods
The hosted login page supports various authentication methods, including traditional username/password authentication, social login options, multi-factor authentication (MFA), and passwordless authentication methods like [passkeys](/docs/concepts/features/passkeys.md).
The second factor (2FA) and multi-factor authentication methods (MFA) available in ZITADEL include OTP via an authenticator app, TOTP via SMS, OTP via email, and U2F.
Developers can configure the authentication methods offered on the login page based on their application's security and usability requirements.
### Enterprise single-sign-on
![Screenshot of ZITADEL console showing different identity provider templates](/img/guides/integrate/login/login-external-idp-templates.png)
With the hosted login page from ZITADEL developers will get the best support for multi-tenancy single-sign-on with third-party identity providers.
ZITADEL acts as an [identity broker](/docs/concepts/features/identity-brokering) between your applications and different external identity providers, reducing the implementation effort for developers.
External Identity providers can be configured for the whole instance or for each organization that represents a group of users such as a B2B customer or organizational unit.
ZITADEL offers various [identity provider templates](/docs/guides/integrate/identity-providers/introduction) to integrate providers such as [Okta](/docs/guides/integrate/identity-providers/okta-oidc), [Entra ID](/docs/guides/integrate/identity-providers/azure-ad-oidc) or on-premise [LDAP](/docs/guides/integrate/identity-providers/ldap).
### Multi-tenancy authentication
ZITADEL simplifies multi-tenancy authentication by securely managing authentication for multiple tenants, called [Organizations](/docs/concepts/structure/organizations), within a single [instance](/docs/concepts/structure/instance).
Key features include:
1. **Secure Tenant Isolation**: Ensures robust security measures to prevent unauthorized access between tenants, maintaining data privacy and compliance. [Managers](/docs/concepts/structure/managers) for an organization have only access to data and configuration within their Organization.
2. **Custom Authentication Configurations**: Allows tailored [authentication settings](/docs/guides/manage/console/default-settings#login-behavior-and-access), [branding](/docs/guides/manage/customize/branding), and policies for each tenant.
3. **Centralized Management**: Provides [centralized administration](/docs/guides/manage/console/managers) for efficient management across all tenants.
4. **Scalability and Flexibility**: Scales seamlessly to accommodate growing organizations of all sizes.
5. **Domain Discovery**: Starting on a central login page, route users to their tenant based on their email address or other user attributes. Authentication settings will be applied automatically based on the organization's policies, this includes routing users seamlessly to third party identity providers like [Entra ID](/docs/guides/integrate/identity-providers/azure-ad-oidc).
### Customization options
While the hosted login page provides a default authentication interface out-of-the-box, ZITADEL offers [customization options](/docs/guides/manage/customize/branding) to tailor the login page to match your application's branding and user experience requirements.
Developers can customize elements such as logos, colors, and messaging to ensure a seamless integration with their application's user interface.
:::info Customization and Branding
The login page can be changed by customizing different branding aspects and you can define a custom domain for the login (eg, login.acme.com).
By default, the displayed branding is defined [based on the user's domain](/docs/guides/solution-scenarios/domain-discovery). In case you want to show the branding of a specific organization by default, you need to either pass a primary domain scope (`urn:zitadel:iam:org:domain:primary:{domainname}`) with the authorization request, or define the behavior on your Project's settings.
:::
### Fast account switching
The hosted login page remembers users who have previously authenticated.
In case a user has used multiple accounts, for example, a private account and a work account, to authenticate, then all accounts will be shown on the Account Picker.
Users can still login with a different user that is not on the list.
This allows users to quickly switch between users and provide a better user experience.
:::info
This behavior can be changed with the authorization request. Please refer to our [guide](/guides/integrate/login/oidc/login-users).
:::
### Self-service for users
ZITADEL's hosted login page offers [many self-service flows](/docs/concepts/features/selfservice) that allow users to set up authentication methods or recover their login information.
Developers use the self-service functionalities to reduce manual tasks and improve user experience.
Key features include:
### Password reset
Unauthenticated users can request a password reset after providing the loginname during the login flow.
- User selects reset password
- An email will be sent to the verified email address
- User opens a link and has to provide a new password
#### Prompt users to set up multifactor authentication
Users are automatically prompted to provide a second factor, when
- Instance or organization [login policy](/concepts/structure/policies#login-policy) is set
- Requested by the client
- A multi-factor is set up for the user
When a multi-factor is required, but not set up, then the user is requested to set up an additional factor.
:::info Disabling multifactor prompt
You can disable the prompt, in case multifactor authentication is not enforced by setting the [**Multifactor Init Lifetime**](/docs/guides/manage/console/default-settings#login-lifetimes) to 0.
:::
#### Enroll passkeys
Users can select a button to initiate passwordless login or use a fall-back method (ie. login with username/password), if available.
The passwordless with [passkeys](/docs/concepts/features/passkeys.md) login flow follows the FIDO2 / WebAuthN standard.
With the introduction of passkeys the gesture can be provided on ANY of the user's devices.
This is not strictly the device where the login flow is being executed (e.g., on a mobile device).
The user experience depends mainly on the operating system and browser.
## Hosted Login Version 2 (Beta)
We have worked on a new, self-hostable implementation of our hosted login built with Next.js and leveraging our [Session API](/docs/guides/integrate/login/login-users#zitadels-session-api).
This solution empowers you to easily fork and customize the login experience to perfectly match your brand and needs.
In this initial release, the new login is available for self-hosting only. We'll be progressively replacing the built-in login with this improved version, built with [TypeScript](https://github.com/zitadel/typescript).
### Current State
Our primary goal for the TypeScript login system is to replace the existing login functionality within Zitadel Core, which is shipped with Zitadel automatically. This will allow us to leverage the benefits of the new system, including its modular architecture and enhanced security features.
To achieve this, we are actively working on implementing the core features currently available in Zitadel Core, such as:
- **Authentication Methods:**
- Username and Password
- Passkeys
- Multi-Factor Authentication (MFA)
- External Identity Providers (OIDC, SAML, etc.)
- **OpenID Connect (OIDC) Compliance:** Adherence to the OIDC standard for seamless integration with various identity providers.
- **Customization**:
- Branding options to match your organization's identity.
- Flexible configuration settings to tailor the login experience.
The full feature list can be found [here](https://github.com/zitadel/typescript?tab=readme-ov-file#features-list).
As we continue to develop the TypeScript login system, we will provide regular updates on its progress and new capabilities.
### Limitations
For the first implementation we have excluded the following features:
- SAML (SP & OP)
- Generic JWT IDP
- LDAP IDP
- Device Authorization Grants
- Timebased features
- Lockout Settings
- Password Expiry Settings
- Login Settings - Multifactor init prompt
- Force MFA on external authenticated users
- Passkey/U2F Setup
- As passkey and u2f is bound to a domain, it is important to notice, that setting up the authentication possibility in the ZITADEL management console (Self-service), will not work if the login runs on a different domain
- Custom Login Texts
### Beta Testing
The TypeScript login system is currently in beta testing. Your feedback is invaluable in helping us refine and improve this new solution.
At your convenience please open any issues faced on our Typescript Login GitHub repository to report bugs or suggest enhancements while more general feedback can be shared directly to fabienne@zitadel.com.
Your contributions will play a crucial role in shaping the future of our login system. Thank you for your support!
#### Step-by-step Guide
The simplest way to deploy the new login for yourself is by using the [“Deploy” button in our repository](https://github.com/zitadel/typescript?tab=readme-ov-file#deploy-to-vercel) to deploy the login directly to your Vercel.
1. [Create a service user](https://zitadel.com/docs/guides/integrate/service-users/personal-access-token#create-a-service-user-with-a-pat) (ZITADEL_SERVICE_USER_ID) with a PAT in your instance
2. Give the user IAM_LOGIN_CLIENT Permissions in the default settings (YOUR_DOMAIN/ui/console/instance?id=organizations)
Note: [Zitadel Manager Guide](https://zitadel.com/docs/guides/manage/console/managers)
3. Deploy login to Vercel: You can do so, be directly clicking the [“Deploy” button](https://github.com/zitadel/typescript?tab=readme-ov-file#deploy-to-vercel) at the bottom of the readme in our [repository](https://github.com/zitadel/typescript)
4. If you have used the deploy button in the steps before, you will automatically be asked for this step. Enter the environment variables in Vercel
- ZITADEL_SERVICE_USER_ID
- PAT
- ZITADEL_API_URL (Example: https://my-domain.zitadel.cloud, no trailing slash)
5. Add the domain where your login UI is hosted to the [trusted domains](https://zitadel.com/docs/apis/resources/admin/admin-service-add-instance-trusted-domain) in Zitadel. (Example: my-new-zitadel-login.vercel.app)
6. Use the new login in your application. You have three different options on how to achieve this
1. Enable the new login on your application configuration and add the URL of your login UI, with that settings Zitadel will automatically redirect you to the new login if you call the old one.
![Login V2 Application Configuration](/img/guides/integrate/login/login-v2-app-config.png)
2. Enable the [loginV2 feature](https://zitadel.com/docs/apis/resources/feature_service_v2/feature-service-set-instance-features) on the instance and add the URL of your login. If you enable this feature, the login will be used for every application configured in your Zitadel instance. (Example: https://my-new-zitadel-login.vercel.app)
3. Change the issuer in the code of your application to the new domain of your login
7. Enforce users to have their email verified. By setting `EMAIL_VERIFICATION` to `true` in your environment variables, your users will be enforced to verify their email address before they can log in.
### Important Notes
As this feature is currently in Beta, please be aware of some potential workarounds and important considerations before implementation.
- **Create Users:** The new typescript login is built with the session and the user V2 API, the users V2 API does have some differences to the v1 API, so make sure you create users through the new API.
- **External IDPs:** If you want to use external identity provider login, such as Login with Google or Apple. You can follow our existing setup guides, just make sure to use the following redirect url: $YOUR-DOMAIN/idps/callback
- **Passkey/U2F:** Those authentication methods are bound to a domain. As your new login runs on a different domain than the previous login, existing passwordless authentication and u2f (fingerprint, face id, etc.) cant be used. Also when they are managed through the management console of ZITADEL, they are added on a different domain.
<br />
*Note: If you run the login on a subdomain of your current instance, this problem
can be avoided. E.g myinstance.zitadel.cloud and login.myinstance.zitadel.cloud*

View File

@ -1,6 +1,6 @@
--- ---
title: Login users into your application with a hosted or custom login UI title: Log users into your application with different authentication options
sidebar_label: Hosted vs. Custom Login UI sidebar_label: Authentication Options
--- ---
ZITADEL is a comprehensive identity and access management platform designed to streamline user authentication, authorization, and management processes for your application. It offers a range of features, including single sign-on (SSO), multi-factor authentication (MFA), and centralized user management. ZITADEL is a comprehensive identity and access management platform designed to streamline user authentication, authorization, and management processes for your application. It offers a range of features, including single sign-on (SSO), multi-factor authentication (MFA), and centralized user management.
@ -25,6 +25,8 @@ The identity provider is not part of the original application, but a standalone
The user will authenticate using their credentials. The user will authenticate using their credentials.
After successful authentication, the user will be redirected back to the original application. After successful authentication, the user will be redirected back to the original application.
If you want to read more about authenticating with OIDC, head over to our comprehensive [OpenID Connect Guide](/docs/integrate/login/oidc).
### Authenticate users with SAML ### Authenticate users with SAML
SAML (Security Assertion Markup Language) is a widely adopted standard for exchanging authentication and authorization data between identity providers and service providers. SAML (Security Assertion Markup Language) is a widely adopted standard for exchanging authentication and authorization data between identity providers and service providers.
@ -52,13 +54,14 @@ Note that SAML might not be suitable for mobile applications.
In case you want to integrate a mobile application, use OpenID Connect or our Session API. In case you want to integrate a mobile application, use OpenID Connect or our Session API.
There are more [differences between SAML and OIDC](https://zitadel.com/blog/saml-vs-oidc) that you might want to consider. There are more [differences between SAML and OIDC](https://zitadel.com/blog/saml-vs-oidc) that you might want to consider.
If you want to read more about authenticating with SAML, head over to our comprehensive [SAML Guide](/docs/integrate/login/saml).
### ZITADEL's Session API ## ZITADEL's Session API
ZITADEL's [Session API](/docs/apis/resources/session_service_v2) provides developers with a straightforward method to manage user sessions within their applications. ZITADEL's [Session API](/docs/apis/resources/session_service_v2) provides developers with a straightforward method to manage user sessions within their applications.
The Session API is not an industry-standard and can be used instead of OpenID Connect or SAML to authenticate users by [building your own custom login user interface](/docs/guides/integrate/login-ui). The Session API is not an industry-standard and can be used instead of OpenID Connect or SAML to authenticate users by [building your own custom login user interface](/docs/guides/integrate/login-ui).
#### Tokens in the Session API ### Tokens in the Session API
The session API will return a session token that can be used to authenticate users from your application. The session API will return a session token that can be used to authenticate users from your application.
This token should not be confused with am access or id tokens in opaque or JWT form that is issued during OpenID connect flows. This token should not be confused with am access or id tokens in opaque or JWT form that is issued during OpenID connect flows.
@ -67,7 +70,7 @@ This token should not be confused with am access or id tokens in opaque or JWT f
Token exchange between Session API and OIDC / SAML tokens is not possible at this moment. Token exchange between Session API and OIDC / SAML tokens is not possible at this moment.
::: :::
#### Key features of the Session API ### Key features of the Session API
These are some key features of the API: These are some key features of the API:
@ -85,127 +88,16 @@ Overall, ZITADEL's Session API simplifies session management within your applica
## Use the Hosted Login to sign-in users ## Use the Hosted Login to sign-in users
ZITADEL provides a hosted single-sign-on page to securely sign-in users to your applications. ZITADEL provides a hosted single-sign-on page for secure user authentication within your applications.
ZITADEL's hosted login page serves as a centralized authentication interface provided for applications that integrate ZITADEL. This centralized authentication interface simplifies application integration by offering a ready-to-use login experience.
As a developer, understanding the hosted login page is essential for seamlessly integrating authentication into your application. For a comprehensive understanding of the hosted login page and its capabilities, please refer to our [dedicated guide](/docs/guides/integrate/login/hosted-login)
### Centralized authentication endpoint The hosted login is particularly well-suited for scenarios where:
- **Minimal branding is required:** If your primary focus is on functionality over a highly customized look and feel.
ZITADEL's hosted login page acts as a centralized authentication endpoint where users are redirected to authenticate themselves. - **Standard authentication flows suffice:** Your application doesn't necessitate complex or unique authentication processes.
When users attempt to access a protected resource within your application, you can redirect them to the hosted login page to authenticate using their login methods and credentials or through Single-sign-on (SSO). - **OIDC or SAML are suitable:** Your application integrates seamlessly with industry-standard protocols.
After successful authentication, the user will be redirected back to the originating application. - **Time-to-market is critical:** You need a rapid and efficient authentication solution to accelerate your development timeline.
- **Embedding the login UI is unnecessary:** You prefer a separate, hosted login page for user authentication.
### Security and compliance
ZITADEL's hosted login page prioritizes security and compliance with industry standards and regulations.
It employs best practices for securing authentication processes, such as encryption, token-based authentication, and adherence to protocols like OAuth 2.0, [OpenID Connect](/docs/guides/integrate/login/oidc), and [SAML](/docs/guides/integrate/login/).
We make sure to harden the login UI and minimize the attack surface.
One of the measures we apply is setting the necessary security heads thus minimizing the risk of common vulnerabilities in login pages, such as XSS vulnerabilities.
Put your current login to the test and compare the results with our hosted login page.
Tools like [Mozilla's Observatory](https://observatory.mozilla.org/) can give you a good first impression about the security posture.
### Developer-friendly integration
Integrating the hosted login page into your application is straightforward, thanks to ZITADEL's developer-friendly documentation, SDKs, and APIs. Developers can easily implement authentication flows, handle authentication callbacks, and customize the user experience to seamlessly integrate authentication with their application's workflow.
Overall, ZITADEL's hosted login page simplifies the authentication process for developers by providing a secure, customizable, and developer-friendly authentication interface. By leveraging this centralized authentication endpoint, developers can enhance their application's security, user experience, and compliance with industry standards and regulations.
## Key features of the hosted login
### Flexible usernames
Different login name formats can be used on ZITADEL's hosted login page to select a user.
Login methods can be a user's username, containing the username and an [organization domain](/docs/guides/manage/console/organizations#domain-verification-and-primary-domain), their email addresses, or their phone numbers.
By default, all of these login methods are allowed and can be adjusted by [Managers](/docs/concepts/structure/managers) to meet their requirements.
### Support for multiple authentication methods
The hosted login page supports various authentication methods, including traditional username/password authentication, social login options, multi-factor authentication (MFA), and passwordless authentication methods like [passkeys](/docs/concepts/features/passkeys.md).
The second factor (2FA) and multi-factor authentication methods (MFA) available in ZITADEL include OTP via an authenticator app, TOTP via SMS, OTP via email, and U2F.
Developers can configure the authentication methods offered on the login page based on their application's security and usability requirements.
### Enterprise single-sign-on
![Screenshot of ZITADEL console showing different identity provider templates](/img/guides/integrate/login/login-external-idp-templates.png)
With the hosted login page from ZITADEL developers will get the best support for multi-tenancy single-sign-on with third-party identity providers.
ZITADEL acts as an [identity broker](/docs/concepts/features/identity-brokering) between your applications and different external identity providers, reducing the implementation effort for developers.
External Identity providers can be configured for the whole instance or for each organization that represents a group of users such as a B2B customer or organizational unit.
ZITADEL offers various [identity provider templates](/docs/guides/integrate/identity-providers/introduction) to integrate providers such as [Okta](/docs/guides/integrate/identity-providers/okta-oidc), [Entra ID](/docs/guides/integrate/identity-providers/azure-ad-oidc) or on-premise [LDAP](/docs/guides/integrate/identity-providers/ldap).
### Multi-tenancy authentication
ZITADEL simplifies multi-tenancy authentication by securely managing authentication for multiple tenants, called [Organizations](/docs/concepts/structure/organizations), within a single [instance](/docs/concepts/structure/instance).
Key features include:
1. **Secure Tenant Isolation**: Ensures robust security measures to prevent unauthorized access between tenants, maintaining data privacy and compliance. [Managers](/docs/concepts/structure/managers) for an organization have only access to data and configuration within their Organization.
2. **Custom Authentication Configurations**: Allows tailored [authentication settings](/docs/guides/manage/console/default-settings#login-behavior-and-access), [branding](/docs/guides/manage/customize/branding), and policies for each tenant.
3. **Centralized Management**: Provides [centralized administration](/docs/guides/manage/console/managers) for efficient management across all tenants.
4. **Scalability and Flexibility**: Scales seamlessly to accommodate growing organizations of all sizes.
5. **Domain Discovery**: Starting on a central login page, route users to their tenant based on their email address or other user attributes. Authentication settings will be applied automatically based on the organization's policies, this includes routing users seamlessly to third party identity providers like [Entra ID](/docs/guides/integrate/identity-providers/azure-ad-oidc).
### Customization options
While the hosted login page provides a default authentication interface out-of-the-box, ZITADEL offers [customization options](/docs/guides/manage/customize/branding) to tailor the login page to match your application's branding and user experience requirements.
Developers can customize elements such as logos, colors, and messaging to ensure a seamless integration with their application's user interface.
:::info Customization and Branding
The login page can be changed by customizing different branding aspects and you can define a custom domain for the login (eg, login.acme.com).
By default, the displayed branding is defined [based on the user's domain](/docs/guides/solution-scenarios/domain-discovery). In case you want to show the branding of a specific organization by default, you need to either pass a primary domain scope (`urn:zitadel:iam:org:domain:primary:{domainname}`) with the authorization request, or define the behavior on your Project's settings.
:::
### Fast account switching
The hosted login page remembers users who have previously authenticated.
In case a user has used multiple accounts, for example, a private account and a work account, to authenticate, then all accounts will be shown on the Account Picker.
Users can still login with a different user that is not on the list.
This allows users to quickly switch between users and provide a better user experience.
:::info
This behavior can be changed with the authorization request. Please refer to our [guide](/guides/integrate/login/oidc/login-users).
:::
### Self-service for users
ZITADEL's hosted login page offers [many self-service flows](/docs/concepts/features/selfservice) that allow users to set up authentication methods or recover their login information.
Developers use the self-service functionalities to reduce manual tasks and improve user experience.
Key features include:
### Password reset
Unauthenticated users can request a password reset after providing the loginname during the login flow.
- User selects reset password
- An email will be sent to the verified email address
- User opens a link and has to provide a new password
#### Prompt users to set up multifactor authentication
Users are automatically prompted to provide a second factor, when
- Instance or organization [login policy](/concepts/structure/policies#login-policy) is set
- Requested by the client
- A multi-factor is set up for the user
When a multi-factor is required, but not set up, then the user is requested to set up an additional factor.
:::info Disabling multifactor prompt
You can disable the prompt, in case multifactor authentication is not enforced by setting the [**Multifactor Init Lifetime**](/docs/guides/manage/console/default-settings#login-lifetimes) to 0.
:::
#### Enroll passkeys
Users can select a button to initiate passwordless login or use a fall-back method (ie. login with username/password), if available.
The passwordless with [passkeys](/docs/concepts/features/passkeys.md) login flow follows the FIDO2 / WebAuthN standard.
With the introduction of passkeys the gesture can be provided on ANY of the user's devices.
This is not strictly the device where the login flow is being executed (e.g., on a mobile device).
The user experience depends mainly on the operating system and browser.
## Build a custom Login UI to authenticate users ## Build a custom Login UI to authenticate users

View File

@ -43,11 +43,10 @@ dotnet add package Zitadel.Api
### Create example client ### Create example client
Change the program.cs file to the content below. This will create a client for the management api and call its `GetMyUsers` function. Change the program.cs file to the content below. This will create a client for the management api and call its `GetMyUsers` function.
The SDK will make sure you will have access to the API by retrieving a Bearer Token using JWT Profile with the provided scopes (`openid` and `urn:zitadel:iam:org:project:id:{projectID}:aud`). The SDK will make sure you will have access to the API by retrieving a Bearer Token using JWT Profile with the provided scopes (`openid` and `urn:zitadel:iam:org:project:id:zitadel:aud`).
Make sure to fill the const `apiUrl`, `apiProject` and `personalAccessToken` with your own instance data. The used vars below are from a test instance, to show you how it should look. Make sure to fill the const `apiUrl`, and `personalAccessToken` with your own instance data. The used vars below are from a test instance, to show you how it should look.
The apiURL is the domain of your instance you can find it on the instance detail in the Customer Portal or in the Console The apiURL is the domain of your instance you can find it on the instance detail in the Customer Portal or in the Console
The apiProject you will find in the ZITADEL project in the first organization of your instance.
```csharp ```csharp
// This file contains two examples: // This file contains two examples:
@ -66,7 +65,8 @@ var client = Clients.AuthService(new(apiUrl, ITokenProvider.Static(personalAcces
var result = await client.GetMyUserAsync(new()); var result = await client.GetMyUserAsync(new());
Console.WriteLine($"User: {result.User}"); Console.WriteLine($"User: {result.User}");
const string apiProject = "170078979166961921"; // This adds the urn:zitadel:iam:org:project:id:zitadel:aud scope to the authorization request, enabling access to ZITADEL APIs.
const string apiProject = "zitadel";
var serviceAccount = ServiceAccount.LoadFromJsonString( var serviceAccount = ServiceAccount.LoadFromJsonString(
@" @"
{ {

View File

@ -0,0 +1,26 @@
---
title: Technical Advisory 10014
---
## Date
Versions: >= v2.67.3, v2.66 >= v2.66.6
Date: 2025-01-17
## Description
Prior to version [v2.66.0](https://github.com/zitadel/zitadel/releases/tag/v2.66.0), some project grants were incorrectly created under the granted organization instead of the project owner's organization. To find these grants, users had to set the `x-zitadel-orgid` header to the granted organization ID when using the [`ListAllProjectGrants`](/apis/resources/mgmt/management-service-add-project-grant) gRPC method.
Zitadel [v2.66.0](https://github.com/zitadel/zitadel/releases/tag/v2.66.0) corrected this behavior for new grants. However, existing grants were not automatically updated. Version v2.66.6 corrects the owner of these existing grants.
## Impact
After the release of v2.66.6, if your application uses the [`ListAllProjectGrants`](/apis/resources/mgmt/management-service-add-project-grant) method with the `x-zitadel-orgid` header set to the granted organization ID, you will not retrieve any results.
## Mitigation
To ensure your application continues to function correctly after the release of v2.66.6, implement the following changes:
1. **Conditional Header:** Only set the `x-zitadel-orgid` header to the project owner's organization ID if the user executing the [`ListAllProjectGrants`](/apis/resources/mgmt/management-service-add-project-grant) method belongs to a different organization than the project.
2. **Use `grantedOrgIdQuery`:** Utilize the `grantedOrgIdQuery` parameter to filter grants for the specific granted organization.

View File

@ -214,6 +214,18 @@ We understand that these advisories may include breaking changes, and we aim to
<td>-</td> <td>-</td>
<td>2024-12-09</td> <td>2024-12-09</td>
</tr> </tr>
<tr>
<td>
<a href="./advisory/a10014">A-10014</a>
</td>
<td>Correction of project grant owner</td>
<td>Breaking Behavior Change</td>
<td>
Correct project grant owners, ensuring they are correctly associated with the projects organization.
</td>
<td>-</td>
<td>2025-01-10</td>
</tr>
</table> </table>
## Subscribe to our Mailing List ## Subscribe to our Mailing List

View File

@ -206,6 +206,31 @@ module.exports = {
}, },
items: [ items: [
"guides/integrate/login/login-users", "guides/integrate/login/login-users",
{
type: "link",
href: "/docs/guides/integrate/login/login-users#zitadels-session-api",
label: "Session API"
},
{
type: "category",
label: "Hosted Login",
link: {
type: "doc",
id: "guides/integrate/login/hosted-login"
},
items: [
{
type: "link",
href: "/docs/guides/integrate/login/hosted-login#hosted-login-version-2-beta",
label: "Login V2 [Beta]"
},
]
},
{
type: "link",
href: "/docs/guides/integrate/login/login-users#build-a-custom-login-ui-to-authenticate-users",
label: "Custom Login UI",
},
{ {
type: "category", type: "category",
label: "OpenID Connect", label: "OpenID Connect",

Binary file not shown.

After

Width:  |  Height:  |  Size: 365 KiB

View File

@ -46,6 +46,19 @@
{ "source": "/docs/examples/call-zitadel-api/go", "destination": "/docs/guides/integrate/zitadel-apis/example-zitadel-api-with-go", "permanent": true }, { "source": "/docs/examples/call-zitadel-api/go", "destination": "/docs/guides/integrate/zitadel-apis/example-zitadel-api-with-go", "permanent": true },
{ "source": "/docs/examples/call-zitadel-api/dot-net", "destination": "/docs/guides/integrate/zitadel-apis/example-zitadel-api-with-dot-net", "permanent": true }, { "source": "/docs/examples/call-zitadel-api/dot-net", "destination": "/docs/guides/integrate/zitadel-apis/example-zitadel-api-with-dot-net", "permanent": true },
{ "source": "/docs/guides/manage/terraform/basics", "destination": "/docs/guides/manage/terraform-provider", "permanent": true }, { "source": "/docs/guides/manage/terraform/basics", "destination": "/docs/guides/manage/terraform-provider", "permanent": true },
{ "source": "/docs/guides/integrate/identity-providers", "destination": "/docs/guides/integrate/identity-providers/introduction", "permanent": true } { "source": "/docs/guides/integrate/identity-providers", "destination": "/docs/guides/integrate/identity-providers/introduction", "permanent": true },
{ "source": "/docs/guides/integrate/login/login-users#centralized-authentication-endpoint", "destination": "/docs/guides/integrate/login/hosted-login#centralized-authentication-endpoint", "permanent": true },
{ "source": "/docs/guides/integrate/login/login-users#security-and-compliance", "destination": "/docs/guides/integrate/login/hosted-login#security-and-compliance", "permanent": true },
{ "source": "/docs/guides/integrate/login/login-users#developer-friendly-integration", "destination": "/docs/guides/integrate/login/hosted-login#developer-friendly-integration", "permanent": true },
{ "source": "/docs/guides/integrate/login/login-users#key-features-of-the-hosted-login", "destination": "/docs/guides/integrate/login/hosted-login#key-features-of-the-hosted-login", "permanent": true },
{ "source": "/docs/guides/integrate/login/login-users#flexible-usernames", "destination": "/docs/guides/integrate/login/hosted-login#flexible-usernames", "permanent": true },
{ "source": "/docs/guides/integrate/login/login-users#support-for-multiple-authentication-methods", "destination": "/docs/guides/integrate/login/hosted-login#support-for-multiple-authentication-methods", "permanent": true },
{ "source": "/docs/guides/integrate/login/login-users#enterprise-single-sign-on", "destination": "/docs/guides/integrate/login/hosted-login#enterprise-single-sign-on", "permanent": true },
{ "source": "/docs/guides/integrate/login/login-users#multi-tenancy-authentication", "destination": "/docs/guides/integrate/login/hosted-login#multi-tenancy-authentication", "permanent": true },
{ "source": "/docs/guides/integrate/login/login-users#customization-options", "destination": "/docs/guides/integrate/login/hosted-login#customization-options", "permanent": true },
{ "source": "/docs/guides/integrate/login/login-users#fast-account-switching", "destination": "/docs/guides/integrate/login/hosted-login#fast-account-switching", "permanent": true },
{ "source": "/docs/guides/integrate/login/login-users#self-service-for-users", "destination": "/docs/guides/integrate/login/hosted-login#self-service-for-users", "permanent": true },
{ "source": "/docs/guides/integrate/login/login-users#password-reset", "destination": "/docs/guides/integrate/login/hosted-login#password-reset", "permanent": true }
] ]
} }

4
go.mod
View File

@ -28,6 +28,8 @@ require (
github.com/go-jose/go-jose/v4 v4.0.4 github.com/go-jose/go-jose/v4 v4.0.4
github.com/go-ldap/ldap/v3 v3.4.8 github.com/go-ldap/ldap/v3 v3.4.8
github.com/go-webauthn/webauthn v0.10.2 github.com/go-webauthn/webauthn v0.10.2
github.com/goccy/go-json v0.10.3
github.com/golang/protobuf v1.5.4
github.com/gorilla/csrf v1.7.2 github.com/gorilla/csrf v1.7.2
github.com/gorilla/mux v1.8.1 github.com/gorilla/mux v1.8.1
github.com/gorilla/schema v1.4.1 github.com/gorilla/schema v1.4.1
@ -106,11 +108,9 @@ require (
github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-sql-driver/mysql v1.7.1 // indirect github.com/go-sql-driver/mysql v1.7.1 // indirect
github.com/go-webauthn/x v0.1.9 // indirect github.com/go-webauthn/x v0.1.9 // indirect
github.com/goccy/go-json v0.10.3 // indirect
github.com/golang-jwt/jwt/v4 v4.5.1 // indirect github.com/golang-jwt/jwt/v4 v4.5.1 // indirect
github.com/golang-jwt/jwt/v5 v5.2.1 // indirect github.com/golang-jwt/jwt/v5 v5.2.1 // indirect
github.com/golang/mock v1.6.0 // indirect github.com/golang/mock v1.6.0 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/go-tpm v0.9.0 // indirect github.com/google/go-tpm v0.9.0 // indirect
github.com/google/pprof v0.0.0-20240528025155-186aa0362fba // indirect github.com/google/pprof v0.0.0-20240528025155-186aa0362fba // indirect
github.com/google/s2a-go v0.1.7 // indirect github.com/google/s2a-go v0.1.7 // indirect

View File

@ -7,6 +7,11 @@ func NewMockContext(instanceID, orgID, userID string) context.Context {
return context.WithValue(ctx, instanceKey, &instance{id: instanceID}) return context.WithValue(ctx, instanceKey, &instance{id: instanceID})
} }
func NewMockContextWithAgent(instanceID, orgID, userID, agentID string) context.Context {
ctx := context.WithValue(context.Background(), dataKey, CtxData{UserID: userID, OrgID: orgID, AgentID: agentID})
return context.WithValue(ctx, instanceKey, &instance{id: instanceID})
}
func NewMockContextWithPermissions(instanceID, orgID, userID string, permissions []string) context.Context { func NewMockContextWithPermissions(instanceID, orgID, userID string, permissions []string) context.Context {
ctx := context.WithValue(context.Background(), dataKey, CtxData{UserID: userID, OrgID: orgID}) ctx := context.WithValue(context.Background(), dataKey, CtxData{UserID: userID, OrgID: orgID})
ctx = context.WithValue(ctx, instanceKey, &instance{id: instanceID}) ctx = context.WithValue(ctx, instanceKey, &instance{id: instanceID})

View File

@ -29,6 +29,7 @@ func systemFeaturesToCommand(req *feature_pb.SetSystemFeaturesRequest) (*command
DisableUserTokenEvent: req.DisableUserTokenEvent, DisableUserTokenEvent: req.DisableUserTokenEvent,
EnableBackChannelLogout: req.EnableBackChannelLogout, EnableBackChannelLogout: req.EnableBackChannelLogout,
LoginV2: loginV2, LoginV2: loginV2,
PermissionCheckV2: req.PermissionCheckV2,
}, nil }, nil
} }
@ -46,6 +47,7 @@ func systemFeaturesToPb(f *query.SystemFeatures) *feature_pb.GetSystemFeaturesRe
DisableUserTokenEvent: featureSourceToFlagPb(&f.DisableUserTokenEvent), DisableUserTokenEvent: featureSourceToFlagPb(&f.DisableUserTokenEvent),
EnableBackChannelLogout: featureSourceToFlagPb(&f.EnableBackChannelLogout), EnableBackChannelLogout: featureSourceToFlagPb(&f.EnableBackChannelLogout),
LoginV2: loginV2ToLoginV2FlagPb(f.LoginV2), LoginV2: loginV2ToLoginV2FlagPb(f.LoginV2),
PermissionCheckV2: featureSourceToFlagPb(&f.PermissionCheckV2),
} }
} }
@ -68,6 +70,7 @@ func instanceFeaturesToCommand(req *feature_pb.SetInstanceFeaturesRequest) (*com
DisableUserTokenEvent: req.DisableUserTokenEvent, DisableUserTokenEvent: req.DisableUserTokenEvent,
EnableBackChannelLogout: req.EnableBackChannelLogout, EnableBackChannelLogout: req.EnableBackChannelLogout,
LoginV2: loginV2, LoginV2: loginV2,
PermissionCheckV2: req.PermissionCheckV2,
}, nil }, nil
} }
@ -87,6 +90,7 @@ func instanceFeaturesToPb(f *query.InstanceFeatures) *feature_pb.GetInstanceFeat
DisableUserTokenEvent: featureSourceToFlagPb(&f.DisableUserTokenEvent), DisableUserTokenEvent: featureSourceToFlagPb(&f.DisableUserTokenEvent),
EnableBackChannelLogout: featureSourceToFlagPb(&f.EnableBackChannelLogout), EnableBackChannelLogout: featureSourceToFlagPb(&f.EnableBackChannelLogout),
LoginV2: loginV2ToLoginV2FlagPb(f.LoginV2), LoginV2: loginV2ToLoginV2FlagPb(f.LoginV2),
PermissionCheckV2: featureSourceToFlagPb(&f.PermissionCheckV2),
} }
} }

View File

@ -101,6 +101,10 @@ func Test_systemFeaturesToPb(t *testing.T) {
BaseURI: &url.URL{Scheme: "https", Host: "login.com"}, BaseURI: &url.URL{Scheme: "https", Host: "login.com"},
}, },
}, },
PermissionCheckV2: query.FeatureSource[bool]{
Level: feature.LevelSystem,
Value: true,
},
} }
want := &feature_pb.GetSystemFeaturesResponse{ want := &feature_pb.GetSystemFeaturesResponse{
Details: &object.Details{ Details: &object.Details{
@ -153,6 +157,10 @@ func Test_systemFeaturesToPb(t *testing.T) {
BaseUri: gu.Ptr("https://login.com"), BaseUri: gu.Ptr("https://login.com"),
Source: feature_pb.Source_SOURCE_SYSTEM, Source: feature_pb.Source_SOURCE_SYSTEM,
}, },
PermissionCheckV2: &feature_pb.FeatureFlag{
Enabled: true,
Source: feature_pb.Source_SOURCE_SYSTEM,
},
} }
got := systemFeaturesToPb(arg) got := systemFeaturesToPb(arg)
assert.Equal(t, want, got) assert.Equal(t, want, got)
@ -252,6 +260,10 @@ func Test_instanceFeaturesToPb(t *testing.T) {
BaseURI: &url.URL{Scheme: "https", Host: "login.com"}, BaseURI: &url.URL{Scheme: "https", Host: "login.com"},
}, },
}, },
PermissionCheckV2: query.FeatureSource[bool]{
Level: feature.LevelInstance,
Value: true,
},
} }
want := &feature_pb.GetInstanceFeaturesResponse{ want := &feature_pb.GetInstanceFeaturesResponse{
Details: &object.Details{ Details: &object.Details{
@ -312,6 +324,10 @@ func Test_instanceFeaturesToPb(t *testing.T) {
BaseUri: gu.Ptr("https://login.com"), BaseUri: gu.Ptr("https://login.com"),
Source: feature_pb.Source_SOURCE_INSTANCE, Source: feature_pb.Source_SOURCE_INSTANCE,
}, },
PermissionCheckV2: &feature_pb.FeatureFlag{
Enabled: true,
Source: feature_pb.Source_SOURCE_INSTANCE,
},
} }
got := instanceFeaturesToPb(arg) got := instanceFeaturesToPb(arg)
assert.Equal(t, want, got) assert.Equal(t, want, got)

View File

@ -34,6 +34,7 @@ func MemberToPb(assetAPIPrefix string, m *query.Member) *member_pb.Member {
m.ChangeDate, m.ChangeDate,
m.ResourceOwner, m.ResourceOwner,
), ),
UserResourceOwner: m.UserResourceOwner,
} }
} }

View File

@ -0,0 +1,714 @@
//go:build integration
package session_test
import (
"context"
"testing"
"time"
"github.com/golang/protobuf/ptypes/timestamp"
"github.com/muhlemmer/gu"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/types/known/durationpb"
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/zitadel/zitadel/internal/integration"
"github.com/zitadel/zitadel/pkg/grpc/object/v2"
"github.com/zitadel/zitadel/pkg/grpc/session/v2"
)
func TestServer_GetSession(t *testing.T) {
type args struct {
ctx context.Context
req *session.GetSessionRequest
dep func(ctx context.Context, t *testing.T, request *session.GetSessionRequest) uint64
}
tests := []struct {
name string
args args
want *session.GetSessionResponse
wantFactors []wantFactor
wantExpirationWindow time.Duration
wantErr bool
}{
{
name: "get session, no id provided",
args: args{
CTX,
&session.GetSessionRequest{
SessionId: "",
},
nil,
},
wantErr: true,
},
{
name: "get session, not found",
args: args{
CTX,
&session.GetSessionRequest{
SessionId: "unknown",
},
nil,
},
wantErr: true,
},
{
name: "get session, no permission",
args: args{
UserCTX,
&session.GetSessionRequest{},
func(ctx context.Context, t *testing.T, request *session.GetSessionRequest) uint64 {
resp, err := Client.CreateSession(ctx, &session.CreateSessionRequest{})
require.NoError(t, err)
request.SessionId = resp.SessionId
return resp.GetDetails().GetSequence()
},
},
wantErr: true,
},
{
name: "get session, permission, ok",
args: args{
CTX,
&session.GetSessionRequest{},
func(ctx context.Context, t *testing.T, request *session.GetSessionRequest) uint64 {
resp, err := Client.CreateSession(ctx, &session.CreateSessionRequest{})
require.NoError(t, err)
request.SessionId = resp.SessionId
return resp.GetDetails().GetSequence()
},
},
want: &session.GetSessionResponse{
Session: &session.Session{},
},
},
{
name: "get session, token, ok",
args: args{
UserCTX,
&session.GetSessionRequest{},
func(ctx context.Context, t *testing.T, request *session.GetSessionRequest) uint64 {
resp, err := Client.CreateSession(ctx, &session.CreateSessionRequest{})
require.NoError(t, err)
request.SessionId = resp.SessionId
request.SessionToken = gu.Ptr(resp.SessionToken)
return resp.GetDetails().GetSequence()
},
},
want: &session.GetSessionResponse{
Session: &session.Session{},
},
},
{
name: "get session, user agent, ok",
args: args{
UserCTX,
&session.GetSessionRequest{},
func(ctx context.Context, t *testing.T, request *session.GetSessionRequest) uint64 {
resp, err := Client.CreateSession(ctx, &session.CreateSessionRequest{
UserAgent: &session.UserAgent{
FingerprintId: gu.Ptr("fingerPrintID"),
Ip: gu.Ptr("1.2.3.4"),
Description: gu.Ptr("Description"),
Header: map[string]*session.UserAgent_HeaderValues{
"foo": {Values: []string{"foo", "bar"}},
},
},
},
)
require.NoError(t, err)
request.SessionId = resp.SessionId
request.SessionToken = gu.Ptr(resp.SessionToken)
return resp.GetDetails().GetSequence()
},
},
want: &session.GetSessionResponse{
Session: &session.Session{
UserAgent: &session.UserAgent{
FingerprintId: gu.Ptr("fingerPrintID"),
Ip: gu.Ptr("1.2.3.4"),
Description: gu.Ptr("Description"),
Header: map[string]*session.UserAgent_HeaderValues{
"foo": {Values: []string{"foo", "bar"}},
},
},
},
},
},
{
name: "get session, lifetime, ok",
args: args{
UserCTX,
&session.GetSessionRequest{},
func(ctx context.Context, t *testing.T, request *session.GetSessionRequest) uint64 {
resp, err := Client.CreateSession(ctx, &session.CreateSessionRequest{
Lifetime: durationpb.New(5 * time.Minute),
},
)
require.NoError(t, err)
request.SessionId = resp.SessionId
request.SessionToken = gu.Ptr(resp.SessionToken)
return resp.GetDetails().GetSequence()
},
},
wantExpirationWindow: 5 * time.Minute,
want: &session.GetSessionResponse{
Session: &session.Session{},
},
},
{
name: "get session, metadata, ok",
args: args{
UserCTX,
&session.GetSessionRequest{},
func(ctx context.Context, t *testing.T, request *session.GetSessionRequest) uint64 {
resp, err := Client.CreateSession(ctx, &session.CreateSessionRequest{
Metadata: map[string][]byte{"foo": []byte("bar")},
},
)
require.NoError(t, err)
request.SessionId = resp.SessionId
request.SessionToken = gu.Ptr(resp.SessionToken)
return resp.GetDetails().GetSequence()
},
},
want: &session.GetSessionResponse{
Session: &session.Session{
Metadata: map[string][]byte{"foo": []byte("bar")},
},
},
},
{
name: "get session, user, ok",
args: args{
UserCTX,
&session.GetSessionRequest{},
func(ctx context.Context, t *testing.T, request *session.GetSessionRequest) uint64 {
resp, err := Client.CreateSession(ctx, &session.CreateSessionRequest{
Checks: &session.Checks{
User: &session.CheckUser{
Search: &session.CheckUser_UserId{
UserId: User.GetUserId(),
},
},
},
},
)
require.NoError(t, err)
request.SessionId = resp.SessionId
request.SessionToken = gu.Ptr(resp.SessionToken)
return resp.GetDetails().GetSequence()
},
},
wantFactors: []wantFactor{wantUserFactor},
want: &session.GetSessionResponse{
Session: &session.Session{},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var sequence uint64
if tt.args.dep != nil {
sequence = tt.args.dep(CTX, t, tt.args.req)
}
retryDuration, tick := integration.WaitForAndTickWithMaxDuration(tt.args.ctx, time.Minute)
require.EventuallyWithT(t, func(ttt *assert.CollectT) {
got, err := Client.GetSession(tt.args.ctx, tt.args.req)
if tt.wantErr {
assert.Error(ttt, err)
return
}
if !assert.NoError(ttt, err) {
return
}
tt.want.Session.Id = tt.args.req.SessionId
tt.want.Session.Sequence = sequence
verifySession(ttt, got.GetSession(), tt.want.GetSession(), time.Minute, tt.wantExpirationWindow, User.GetUserId(), tt.wantFactors...)
}, retryDuration, tick)
})
}
}
type sessionAttr struct {
ID string
UserID string
UserAgent string
CreationDate *timestamp.Timestamp
ChangeDate *timestamppb.Timestamp
Details *object.Details
}
type sessionAttrs []*sessionAttr
func (u sessionAttrs) ids() []string {
ids := make([]string, len(u))
for i := range u {
ids[i] = u[i].ID
}
return ids
}
func createSessions(ctx context.Context, t *testing.T, count int, userID string, userAgent string, lifetime *durationpb.Duration, metadata map[string][]byte) sessionAttrs {
infos := make([]*sessionAttr, count)
for i := 0; i < count; i++ {
infos[i] = createSession(ctx, t, userID, userAgent, lifetime, metadata)
}
return infos
}
func createSession(ctx context.Context, t *testing.T, userID string, userAgent string, lifetime *durationpb.Duration, metadata map[string][]byte) *sessionAttr {
req := &session.CreateSessionRequest{}
if userID != "" {
req.Checks = &session.Checks{
User: &session.CheckUser{
Search: &session.CheckUser_UserId{
UserId: userID,
},
},
}
}
if userAgent != "" {
req.UserAgent = &session.UserAgent{
FingerprintId: gu.Ptr(userAgent),
Ip: gu.Ptr("1.2.3.4"),
Description: gu.Ptr("Description"),
Header: map[string]*session.UserAgent_HeaderValues{
"foo": {Values: []string{"foo", "bar"}},
},
}
}
if lifetime != nil {
req.Lifetime = lifetime
}
if metadata != nil {
req.Metadata = metadata
}
resp, err := Client.CreateSession(ctx, req)
require.NoError(t, err)
return &sessionAttr{
resp.GetSessionId(),
userID,
userAgent,
resp.GetDetails().GetChangeDate(),
resp.GetDetails().GetChangeDate(),
resp.GetDetails(),
}
}
func TestServer_ListSessions(t *testing.T) {
type args struct {
ctx context.Context
req *session.ListSessionsRequest
dep func(ctx context.Context, t *testing.T, request *session.ListSessionsRequest) []*sessionAttr
}
tests := []struct {
name string
args args
want *session.ListSessionsResponse
wantFactors []wantFactor
wantExpirationWindow time.Duration
wantErr bool
}{
{
name: "list sessions, not found",
args: args{
CTX,
&session.ListSessionsRequest{
Queries: []*session.SearchQuery{
{Query: &session.SearchQuery_IdsQuery{IdsQuery: &session.IDsQuery{Ids: []string{"unknown"}}}},
},
},
func(ctx context.Context, t *testing.T, request *session.ListSessionsRequest) []*sessionAttr {
return []*sessionAttr{}
},
},
want: &session.ListSessionsResponse{
Details: &object.ListDetails{
TotalResult: 0,
Timestamp: timestamppb.Now(),
},
Sessions: []*session.Session{},
},
},
{
name: "list sessions, no permission",
args: args{
UserCTX,
&session.ListSessionsRequest{
Queries: []*session.SearchQuery{},
},
func(ctx context.Context, t *testing.T, request *session.ListSessionsRequest) []*sessionAttr {
info := createSession(ctx, t, "", "", nil, nil)
request.Queries = append(request.Queries, &session.SearchQuery{Query: &session.SearchQuery_IdsQuery{IdsQuery: &session.IDsQuery{Ids: []string{info.ID}}}})
return []*sessionAttr{}
},
},
want: &session.ListSessionsResponse{
Details: &object.ListDetails{
TotalResult: 1,
Timestamp: timestamppb.Now(),
},
Sessions: []*session.Session{},
},
},
{
name: "list sessions, permission, ok",
args: args{
CTX,
&session.ListSessionsRequest{},
func(ctx context.Context, t *testing.T, request *session.ListSessionsRequest) []*sessionAttr {
info := createSession(ctx, t, "", "", nil, nil)
request.Queries = append(request.Queries, &session.SearchQuery{Query: &session.SearchQuery_IdsQuery{IdsQuery: &session.IDsQuery{Ids: []string{info.ID}}}})
return []*sessionAttr{info}
},
},
want: &session.ListSessionsResponse{
Details: &object.ListDetails{
TotalResult: 1,
Timestamp: timestamppb.Now(),
},
Sessions: []*session.Session{{}},
},
},
{
name: "list sessions, full, ok",
args: args{
CTX,
&session.ListSessionsRequest{},
func(ctx context.Context, t *testing.T, request *session.ListSessionsRequest) []*sessionAttr {
info := createSession(ctx, t, User.GetUserId(), "agent", durationpb.New(time.Minute*5), map[string][]byte{"key": []byte("value")})
request.Queries = append(request.Queries, &session.SearchQuery{Query: &session.SearchQuery_IdsQuery{IdsQuery: &session.IDsQuery{Ids: []string{info.ID}}}})
return []*sessionAttr{info}
},
},
wantExpirationWindow: time.Minute * 5,
wantFactors: []wantFactor{wantUserFactor},
want: &session.ListSessionsResponse{
Details: &object.ListDetails{
TotalResult: 1,
Timestamp: timestamppb.Now(),
},
Sessions: []*session.Session{
{
Metadata: map[string][]byte{"key": []byte("value")},
UserAgent: &session.UserAgent{
FingerprintId: gu.Ptr("agent"),
Ip: gu.Ptr("1.2.3.4"),
Description: gu.Ptr("Description"),
Header: map[string]*session.UserAgent_HeaderValues{
"foo": {Values: []string{"foo", "bar"}},
},
},
},
},
},
},
{
name: "list sessions, multiple, ok",
args: args{
CTX,
&session.ListSessionsRequest{},
func(ctx context.Context, t *testing.T, request *session.ListSessionsRequest) []*sessionAttr {
infos := createSessions(ctx, t, 3, User.GetUserId(), "agent", durationpb.New(time.Minute*5), map[string][]byte{"key": []byte("value")})
request.Queries = append(request.Queries, &session.SearchQuery{Query: &session.SearchQuery_IdsQuery{IdsQuery: &session.IDsQuery{Ids: infos.ids()}}})
return infos
},
},
wantExpirationWindow: time.Minute * 5,
wantFactors: []wantFactor{wantUserFactor},
want: &session.ListSessionsResponse{
Details: &object.ListDetails{
TotalResult: 3,
Timestamp: timestamppb.Now(),
},
Sessions: []*session.Session{
{
Metadata: map[string][]byte{"key": []byte("value")},
UserAgent: &session.UserAgent{
FingerprintId: gu.Ptr("agent"),
Ip: gu.Ptr("1.2.3.4"),
Description: gu.Ptr("Description"),
Header: map[string]*session.UserAgent_HeaderValues{
"foo": {Values: []string{"foo", "bar"}},
},
},
},
{
Metadata: map[string][]byte{"key": []byte("value")},
UserAgent: &session.UserAgent{
FingerprintId: gu.Ptr("agent"),
Ip: gu.Ptr("1.2.3.4"),
Description: gu.Ptr("Description"),
Header: map[string]*session.UserAgent_HeaderValues{
"foo": {Values: []string{"foo", "bar"}},
},
},
},
{
Metadata: map[string][]byte{"key": []byte("value")},
UserAgent: &session.UserAgent{
FingerprintId: gu.Ptr("agent"),
Ip: gu.Ptr("1.2.3.4"),
Description: gu.Ptr("Description"),
Header: map[string]*session.UserAgent_HeaderValues{
"foo": {Values: []string{"foo", "bar"}},
},
},
},
},
},
},
{
name: "list sessions, userid, ok",
args: args{
CTX,
&session.ListSessionsRequest{},
func(ctx context.Context, t *testing.T, request *session.ListSessionsRequest) []*sessionAttr {
createdUser := createFullUser(ctx)
info := createSession(ctx, t, createdUser.GetUserId(), "agent", durationpb.New(time.Minute*5), map[string][]byte{"key": []byte("value")})
request.Queries = append(request.Queries, &session.SearchQuery{Query: &session.SearchQuery_UserIdQuery{UserIdQuery: &session.UserIDQuery{Id: createdUser.GetUserId()}}})
return []*sessionAttr{info}
},
},
wantExpirationWindow: time.Minute * 5,
wantFactors: []wantFactor{wantUserFactor},
want: &session.ListSessionsResponse{
Details: &object.ListDetails{
TotalResult: 1,
Timestamp: timestamppb.Now(),
},
Sessions: []*session.Session{
{
Metadata: map[string][]byte{"key": []byte("value")},
UserAgent: &session.UserAgent{
FingerprintId: gu.Ptr("agent"),
Ip: gu.Ptr("1.2.3.4"),
Description: gu.Ptr("Description"),
Header: map[string]*session.UserAgent_HeaderValues{
"foo": {Values: []string{"foo", "bar"}},
},
},
},
},
},
},
{
name: "list sessions, own creator, ok",
args: args{
CTX,
&session.ListSessionsRequest{},
func(ctx context.Context, t *testing.T, request *session.ListSessionsRequest) []*sessionAttr {
info := createSession(ctx, t, User.GetUserId(), "agent", durationpb.New(time.Minute*5), map[string][]byte{"key": []byte("value")})
request.Queries = append(request.Queries,
&session.SearchQuery{Query: &session.SearchQuery_IdsQuery{IdsQuery: &session.IDsQuery{Ids: []string{info.ID}}}},
&session.SearchQuery{Query: &session.SearchQuery_CreatorQuery{CreatorQuery: &session.CreatorQuery{}}})
return []*sessionAttr{info}
},
},
wantExpirationWindow: time.Minute * 5,
wantFactors: []wantFactor{wantUserFactor},
want: &session.ListSessionsResponse{
Details: &object.ListDetails{
TotalResult: 1,
Timestamp: timestamppb.Now(),
},
Sessions: []*session.Session{
{
Metadata: map[string][]byte{"key": []byte("value")},
UserAgent: &session.UserAgent{
FingerprintId: gu.Ptr("agent"),
Ip: gu.Ptr("1.2.3.4"),
Description: gu.Ptr("Description"),
Header: map[string]*session.UserAgent_HeaderValues{
"foo": {Values: []string{"foo", "bar"}},
},
},
},
},
},
},
{
name: "list sessions, creator, ok",
args: args{
IAMOwnerCTX,
&session.ListSessionsRequest{},
func(ctx context.Context, t *testing.T, request *session.ListSessionsRequest) []*sessionAttr {
info := createSession(ctx, t, User.GetUserId(), "agent", durationpb.New(time.Minute*5), map[string][]byte{"key": []byte("value")})
request.Queries = append(request.Queries,
&session.SearchQuery{Query: &session.SearchQuery_IdsQuery{IdsQuery: &session.IDsQuery{Ids: []string{info.ID}}}},
&session.SearchQuery{Query: &session.SearchQuery_CreatorQuery{CreatorQuery: &session.CreatorQuery{Id: gu.Ptr(Instance.Users.Get(integration.UserTypeOrgOwner).ID)}}})
return []*sessionAttr{info}
},
},
wantExpirationWindow: time.Minute * 5,
wantFactors: []wantFactor{wantUserFactor},
want: &session.ListSessionsResponse{
Details: &object.ListDetails{
TotalResult: 1,
Timestamp: timestamppb.Now(),
},
Sessions: []*session.Session{
{
Metadata: map[string][]byte{"key": []byte("value")},
UserAgent: &session.UserAgent{
FingerprintId: gu.Ptr("agent"),
Ip: gu.Ptr("1.2.3.4"),
Description: gu.Ptr("Description"),
Header: map[string]*session.UserAgent_HeaderValues{
"foo": {Values: []string{"foo", "bar"}},
},
},
},
},
},
},
{
name: "list sessions, wrong creator",
args: args{
IAMOwnerCTX,
&session.ListSessionsRequest{},
func(ctx context.Context, t *testing.T, request *session.ListSessionsRequest) []*sessionAttr {
info := createSession(ctx, t, User.GetUserId(), "agent", durationpb.New(time.Minute*5), map[string][]byte{"key": []byte("value")})
request.Queries = append(request.Queries,
&session.SearchQuery{Query: &session.SearchQuery_IdsQuery{IdsQuery: &session.IDsQuery{Ids: []string{info.ID}}}},
&session.SearchQuery{Query: &session.SearchQuery_CreatorQuery{CreatorQuery: &session.CreatorQuery{}}})
return []*sessionAttr{}
},
},
wantExpirationWindow: time.Minute * 5,
wantFactors: []wantFactor{wantUserFactor},
want: &session.ListSessionsResponse{
Details: &object.ListDetails{
TotalResult: 0,
Timestamp: timestamppb.Now(),
},
Sessions: []*session.Session{},
},
},
{
name: "list sessions, empty creator",
args: args{
IAMOwnerCTX,
&session.ListSessionsRequest{},
func(ctx context.Context, t *testing.T, request *session.ListSessionsRequest) []*sessionAttr {
request.Queries = append(request.Queries,
&session.SearchQuery{Query: &session.SearchQuery_CreatorQuery{CreatorQuery: &session.CreatorQuery{Id: gu.Ptr("")}}})
return []*sessionAttr{}
},
},
wantExpirationWindow: time.Minute * 5,
wantFactors: []wantFactor{wantUserFactor},
wantErr: true,
},
{
name: "list sessions, useragent, ok",
args: args{
IAMOwnerCTX,
&session.ListSessionsRequest{},
func(ctx context.Context, t *testing.T, request *session.ListSessionsRequest) []*sessionAttr {
info := createSession(ctx, t, User.GetUserId(), "useragent", durationpb.New(time.Minute*5), map[string][]byte{"key": []byte("value")})
request.Queries = append(request.Queries,
&session.SearchQuery{Query: &session.SearchQuery_IdsQuery{IdsQuery: &session.IDsQuery{Ids: []string{info.ID}}}},
&session.SearchQuery{Query: &session.SearchQuery_UserAgentQuery{UserAgentQuery: &session.UserAgentQuery{FingerprintId: gu.Ptr("useragent")}}})
return []*sessionAttr{info}
},
},
wantExpirationWindow: time.Minute * 5,
wantFactors: []wantFactor{wantUserFactor},
want: &session.ListSessionsResponse{
Details: &object.ListDetails{
TotalResult: 1,
Timestamp: timestamppb.Now(),
},
Sessions: []*session.Session{
{
Metadata: map[string][]byte{"key": []byte("value")},
UserAgent: &session.UserAgent{
FingerprintId: gu.Ptr("useragent"),
Ip: gu.Ptr("1.2.3.4"),
Description: gu.Ptr("Description"),
Header: map[string]*session.UserAgent_HeaderValues{
"foo": {Values: []string{"foo", "bar"}},
},
},
},
},
},
},
{
name: "list sessions, wrong useragent",
args: args{
IAMOwnerCTX,
&session.ListSessionsRequest{},
func(ctx context.Context, t *testing.T, request *session.ListSessionsRequest) []*sessionAttr {
info := createSession(ctx, t, User.GetUserId(), "useragent", durationpb.New(time.Minute*5), map[string][]byte{"key": []byte("value")})
request.Queries = append(request.Queries,
&session.SearchQuery{Query: &session.SearchQuery_IdsQuery{IdsQuery: &session.IDsQuery{Ids: []string{info.ID}}}},
&session.SearchQuery{Query: &session.SearchQuery_UserAgentQuery{UserAgentQuery: &session.UserAgentQuery{FingerprintId: gu.Ptr("wronguseragent")}}})
return []*sessionAttr{}
},
},
wantExpirationWindow: time.Minute * 5,
wantFactors: []wantFactor{wantUserFactor},
want: &session.ListSessionsResponse{
Details: &object.ListDetails{
TotalResult: 0,
Timestamp: timestamppb.Now(),
},
Sessions: []*session.Session{},
},
},
{
name: "list sessions, empty useragent",
args: args{
IAMOwnerCTX,
&session.ListSessionsRequest{},
func(ctx context.Context, t *testing.T, request *session.ListSessionsRequest) []*sessionAttr {
request.Queries = append(request.Queries,
&session.SearchQuery{Query: &session.SearchQuery_UserAgentQuery{UserAgentQuery: &session.UserAgentQuery{FingerprintId: gu.Ptr("")}}})
return []*sessionAttr{}
},
},
wantExpirationWindow: time.Minute * 5,
wantFactors: []wantFactor{wantUserFactor},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
infos := tt.args.dep(CTX, t, tt.args.req)
retryDuration, tick := integration.WaitForAndTickWithMaxDuration(tt.args.ctx, time.Minute)
require.EventuallyWithT(t, func(ttt *assert.CollectT) {
got, err := Client.ListSessions(tt.args.ctx, tt.args.req)
if tt.wantErr {
assert.Error(ttt, err)
return
}
if !assert.NoError(ttt, err) {
return
}
if !assert.Equal(ttt, got.Details.TotalResult, tt.want.Details.TotalResult) || !assert.Len(ttt, got.Sessions, len(tt.want.Sessions)) {
return
}
for i := range infos {
tt.want.Sessions[i].Id = infos[i].ID
tt.want.Sessions[i].Sequence = infos[i].Details.GetSequence()
tt.want.Sessions[i].CreationDate = infos[i].Details.GetChangeDate()
tt.want.Sessions[i].ChangeDate = infos[i].Details.GetChangeDate()
verifySession(ttt, got.Sessions[i], tt.want.Sessions[i], time.Minute, tt.wantExpirationWindow, infos[i].UserID, tt.wantFactors...)
}
integration.AssertListDetails(ttt, tt.want, got)
}, retryDuration, tick)
})
}
}

View File

@ -0,0 +1,74 @@
//go:build integration
package session_test
import (
"context"
"os"
"testing"
"time"
"github.com/zitadel/logging"
"github.com/zitadel/zitadel/internal/integration"
"github.com/zitadel/zitadel/pkg/grpc/session/v2"
"github.com/zitadel/zitadel/pkg/grpc/user/v2"
)
var (
CTX context.Context
IAMOwnerCTX context.Context
UserCTX context.Context
Instance *integration.Instance
Client session.SessionServiceClient
User *user.AddHumanUserResponse
DeactivatedUser *user.AddHumanUserResponse
LockedUser *user.AddHumanUserResponse
)
func TestMain(m *testing.M) {
os.Exit(func() int {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
defer cancel()
Instance = integration.NewInstance(ctx)
Client = Instance.Client.SessionV2
CTX = Instance.WithAuthorization(ctx, integration.UserTypeOrgOwner)
IAMOwnerCTX = Instance.WithAuthorization(ctx, integration.UserTypeIAMOwner)
UserCTX = Instance.WithAuthorization(ctx, integration.UserTypeNoPermission)
User = createFullUser(CTX)
DeactivatedUser = createDeactivatedUser(CTX)
LockedUser = createLockedUser(CTX)
return m.Run()
}())
}
func createFullUser(ctx context.Context) *user.AddHumanUserResponse {
userResp := Instance.CreateHumanUser(ctx)
Instance.Client.UserV2.VerifyEmail(ctx, &user.VerifyEmailRequest{
UserId: userResp.GetUserId(),
VerificationCode: userResp.GetEmailCode(),
})
Instance.Client.UserV2.VerifyPhone(ctx, &user.VerifyPhoneRequest{
UserId: userResp.GetUserId(),
VerificationCode: userResp.GetPhoneCode(),
})
Instance.SetUserPassword(ctx, userResp.GetUserId(), integration.UserPassword, false)
Instance.RegisterUserPasskey(ctx, userResp.GetUserId())
return userResp
}
func createDeactivatedUser(ctx context.Context) *user.AddHumanUserResponse {
userResp := Instance.CreateHumanUser(ctx)
_, err := Instance.Client.UserV2.DeactivateUser(ctx, &user.DeactivateUserRequest{UserId: userResp.GetUserId()})
logging.OnError(err).Fatal("deactivate human user")
return userResp
}
func createLockedUser(ctx context.Context) *user.AddHumanUserResponse {
userResp := Instance.CreateHumanUser(ctx)
_, err := Instance.Client.UserV2.LockUser(ctx, &user.LockUserRequest{UserId: userResp.GetUserId()})
logging.OnError(err).Fatal("lock human user")
return userResp
}

View File

@ -5,7 +5,6 @@ package session_test
import ( import (
"context" "context"
"fmt" "fmt"
"os"
"testing" "testing"
"time" "time"
@ -14,7 +13,6 @@ import (
"github.com/pquerna/otp/totp" "github.com/pquerna/otp/totp"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/zitadel/logging"
"google.golang.org/grpc/codes" "google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata" "google.golang.org/grpc/metadata"
"google.golang.org/grpc/status" "google.golang.org/grpc/status"
@ -29,63 +27,7 @@ import (
"github.com/zitadel/zitadel/pkg/grpc/user/v2" "github.com/zitadel/zitadel/pkg/grpc/user/v2"
) )
var ( func verifyCurrentSession(t *testing.T, id, token string, sequence uint64, window time.Duration, metadata map[string][]byte, userAgent *session.UserAgent, expirationWindow time.Duration, userID string, factors ...wantFactor) *session.Session {
CTX context.Context
IAMOwnerCTX context.Context
Instance *integration.Instance
Client session.SessionServiceClient
User *user.AddHumanUserResponse
DeactivatedUser *user.AddHumanUserResponse
LockedUser *user.AddHumanUserResponse
)
func TestMain(m *testing.M) {
os.Exit(func() int {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
defer cancel()
Instance = integration.NewInstance(ctx)
Client = Instance.Client.SessionV2
CTX = Instance.WithAuthorization(ctx, integration.UserTypeOrgOwner)
IAMOwnerCTX = Instance.WithAuthorization(ctx, integration.UserTypeIAMOwner)
User = createFullUser(CTX)
DeactivatedUser = createDeactivatedUser(CTX)
LockedUser = createLockedUser(CTX)
return m.Run()
}())
}
func createFullUser(ctx context.Context) *user.AddHumanUserResponse {
userResp := Instance.CreateHumanUser(ctx)
Instance.Client.UserV2.VerifyEmail(ctx, &user.VerifyEmailRequest{
UserId: userResp.GetUserId(),
VerificationCode: userResp.GetEmailCode(),
})
Instance.Client.UserV2.VerifyPhone(ctx, &user.VerifyPhoneRequest{
UserId: userResp.GetUserId(),
VerificationCode: userResp.GetPhoneCode(),
})
Instance.SetUserPassword(ctx, userResp.GetUserId(), integration.UserPassword, false)
Instance.RegisterUserPasskey(ctx, userResp.GetUserId())
return userResp
}
func createDeactivatedUser(ctx context.Context) *user.AddHumanUserResponse {
userResp := Instance.CreateHumanUser(ctx)
_, err := Instance.Client.UserV2.DeactivateUser(ctx, &user.DeactivateUserRequest{UserId: userResp.GetUserId()})
logging.OnError(err).Fatal("deactivate human user")
return userResp
}
func createLockedUser(ctx context.Context) *user.AddHumanUserResponse {
userResp := Instance.CreateHumanUser(ctx)
_, err := Instance.Client.UserV2.LockUser(ctx, &user.LockUserRequest{UserId: userResp.GetUserId()})
logging.OnError(err).Fatal("lock human user")
return userResp
}
func verifyCurrentSession(t testing.TB, id, token string, sequence uint64, window time.Duration, metadata map[string][]byte, userAgent *session.UserAgent, expirationWindow time.Duration, userID string, factors ...wantFactor) *session.Session {
t.Helper() t.Helper()
require.NotEmpty(t, id) require.NotEmpty(t, id)
require.NotEmpty(t, token) require.NotEmpty(t, token)
@ -96,15 +38,25 @@ func verifyCurrentSession(t testing.TB, id, token string, sequence uint64, windo
}) })
require.NoError(t, err) require.NoError(t, err)
s := resp.GetSession() s := resp.GetSession()
want := &session.Session{
Id: id,
Sequence: sequence,
Metadata: metadata,
UserAgent: userAgent,
}
verifySession(t, s, want, window, expirationWindow, userID, factors...)
return s
}
assert.Equal(t, id, s.GetId()) func verifySession(t assert.TestingT, s *session.Session, want *session.Session, window time.Duration, expirationWindow time.Duration, userID string, factors ...wantFactor) {
assert.Equal(t, want.Id, s.GetId())
assert.WithinRange(t, s.GetCreationDate().AsTime(), time.Now().Add(-window), time.Now().Add(window)) assert.WithinRange(t, s.GetCreationDate().AsTime(), time.Now().Add(-window), time.Now().Add(window))
assert.WithinRange(t, s.GetChangeDate().AsTime(), time.Now().Add(-window), time.Now().Add(window)) assert.WithinRange(t, s.GetChangeDate().AsTime(), time.Now().Add(-window), time.Now().Add(window))
assert.Equal(t, sequence, s.GetSequence()) assert.Equal(t, want.Sequence, s.GetSequence())
assert.Equal(t, metadata, s.GetMetadata()) assert.Equal(t, want.Metadata, s.GetMetadata())
if !proto.Equal(userAgent, s.GetUserAgent()) { if !proto.Equal(want.UserAgent, s.GetUserAgent()) {
t.Errorf("user agent =\n%v\nwant\n%v", s.GetUserAgent(), userAgent) t.Errorf("user agent =\n%v\nwant\n%v", s.GetUserAgent(), want.UserAgent)
} }
if expirationWindow == 0 { if expirationWindow == 0 {
assert.Nil(t, s.GetExpirationDate()) assert.Nil(t, s.GetExpirationDate())
@ -113,7 +65,6 @@ func verifyCurrentSession(t testing.TB, id, token string, sequence uint64, windo
} }
verifyFactors(t, s.GetFactors(), window, userID, factors) verifyFactors(t, s.GetFactors(), window, userID, factors)
return s
} }
type wantFactor int type wantFactor int
@ -129,7 +80,7 @@ const (
wantOTPEmailFactor wantOTPEmailFactor
) )
func verifyFactors(t testing.TB, factors *session.Factors, window time.Duration, userID string, want []wantFactor) { func verifyFactors(t assert.TestingT, factors *session.Factors, window time.Duration, userID string, want []wantFactor) {
for _, w := range want { for _, w := range want {
switch w { switch w {
case wantUserFactor: case wantUserFactor:
@ -194,8 +145,15 @@ func TestServer_CreateSession(t *testing.T) {
}, },
}, },
{ {
name: "user agent", name: "full session",
req: &session.CreateSessionRequest{ req: &session.CreateSessionRequest{
Checks: &session.Checks{
User: &session.CheckUser{
Search: &session.CheckUser_UserId{
UserId: User.GetUserId(),
},
},
},
Metadata: map[string][]byte{"foo": []byte("bar")}, Metadata: map[string][]byte{"foo": []byte("bar")},
UserAgent: &session.UserAgent{ UserAgent: &session.UserAgent{
FingerprintId: gu.Ptr("fingerPrintID"), FingerprintId: gu.Ptr("fingerPrintID"),
@ -205,6 +163,7 @@ func TestServer_CreateSession(t *testing.T) {
"foo": {Values: []string{"foo", "bar"}}, "foo": {Values: []string{"foo", "bar"}},
}, },
}, },
Lifetime: durationpb.New(5 * time.Minute),
}, },
want: &session.CreateSessionResponse{ want: &session.CreateSessionResponse{
Details: &object.Details{ Details: &object.Details{
@ -212,14 +171,6 @@ func TestServer_CreateSession(t *testing.T) {
ResourceOwner: Instance.ID(), ResourceOwner: Instance.ID(),
}, },
}, },
wantUserAgent: &session.UserAgent{
FingerprintId: gu.Ptr("fingerPrintID"),
Ip: gu.Ptr("1.2.3.4"),
Description: gu.Ptr("Description"),
Header: map[string]*session.UserAgent_HeaderValues{
"foo": {Values: []string{"foo", "bar"}},
},
},
}, },
{ {
name: "negative lifetime", name: "negative lifetime",
@ -229,40 +180,6 @@ func TestServer_CreateSession(t *testing.T) {
}, },
wantErr: true, wantErr: true,
}, },
{
name: "lifetime",
req: &session.CreateSessionRequest{
Metadata: map[string][]byte{"foo": []byte("bar")},
Lifetime: durationpb.New(5 * time.Minute),
},
want: &session.CreateSessionResponse{
Details: &object.Details{
ChangeDate: timestamppb.Now(),
ResourceOwner: Instance.ID(),
},
},
wantExpirationWindow: 5 * time.Minute,
},
{
name: "with user",
req: &session.CreateSessionRequest{
Checks: &session.Checks{
User: &session.CheckUser{
Search: &session.CheckUser_UserId{
UserId: User.GetUserId(),
},
},
},
Metadata: map[string][]byte{"foo": []byte("bar")},
},
want: &session.CreateSessionResponse{
Details: &object.Details{
ChangeDate: timestamppb.Now(),
ResourceOwner: Instance.ID(),
},
},
wantFactors: []wantFactor{wantUserFactor},
},
{ {
name: "deactivated user", name: "deactivated user",
req: &session.CreateSessionRequest{ req: &session.CreateSessionRequest{
@ -340,8 +257,6 @@ func TestServer_CreateSession(t *testing.T) {
} }
require.NoError(t, err) require.NoError(t, err)
integration.AssertDetails(t, tt.want, got) integration.AssertDetails(t, tt.want, got)
verifyCurrentSession(t, got.GetSessionId(), got.GetSessionToken(), got.GetDetails().GetSequence(), time.Minute, tt.req.GetMetadata(), tt.wantUserAgent, tt.wantExpirationWindow, User.GetUserId(), tt.wantFactors...)
}) })
} }
} }
@ -946,21 +861,30 @@ func Test_ZITADEL_API_missing_authentication(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
ctx := metadata.AppendToOutgoingContext(context.Background(), "Authorization", fmt.Sprintf("Bearer %s", createResp.GetSessionToken())) ctx := metadata.AppendToOutgoingContext(context.Background(), "Authorization", fmt.Sprintf("Bearer %s", createResp.GetSessionToken()))
sessionResp, err := Client.GetSession(ctx, &session.GetSessionRequest{SessionId: createResp.GetSessionId()}) retryDuration, tick := integration.WaitForAndTickWithMaxDuration(ctx, time.Minute)
require.Error(t, err) require.EventuallyWithT(t, func(tt *assert.CollectT) {
require.Nil(t, sessionResp) sessionResp, err := Client.GetSession(ctx, &session.GetSessionRequest{SessionId: createResp.GetSessionId()})
if !assert.Error(tt, err) {
return
}
assert.Nil(tt, sessionResp)
}, retryDuration, tick)
} }
func Test_ZITADEL_API_success(t *testing.T) { func Test_ZITADEL_API_success(t *testing.T) {
id, token, _, _ := Instance.CreateVerifiedWebAuthNSession(t, CTX, User.GetUserId()) id, token, _, _ := Instance.CreateVerifiedWebAuthNSession(t, CTX, User.GetUserId())
ctx := integration.WithAuthorizationToken(context.Background(), token) ctx := integration.WithAuthorizationToken(context.Background(), token)
sessionResp, err := Client.GetSession(ctx, &session.GetSessionRequest{SessionId: id})
require.NoError(t, err)
webAuthN := sessionResp.GetSession().GetFactors().GetWebAuthN() retryDuration, tick := integration.WaitForAndTickWithMaxDuration(ctx, time.Minute)
require.NotNil(t, id, webAuthN.GetVerifiedAt().AsTime()) require.EventuallyWithT(t, func(tt *assert.CollectT) {
require.True(t, webAuthN.GetUserVerified()) sessionResp, err := Client.GetSession(ctx, &session.GetSessionRequest{SessionId: id})
if !assert.NoError(tt, err) {
return
}
webAuthN := sessionResp.GetSession().GetFactors().GetWebAuthN()
assert.NotNil(tt, id, webAuthN.GetVerifiedAt().AsTime())
assert.True(tt, webAuthN.GetUserVerified())
}, retryDuration, tick)
} }
func Test_ZITADEL_API_session_not_found(t *testing.T) { func Test_ZITADEL_API_session_not_found(t *testing.T) {
@ -968,18 +892,30 @@ func Test_ZITADEL_API_session_not_found(t *testing.T) {
// test session token works // test session token works
ctx := integration.WithAuthorizationToken(context.Background(), token) ctx := integration.WithAuthorizationToken(context.Background(), token)
_, err := Client.GetSession(ctx, &session.GetSessionRequest{SessionId: id})
require.NoError(t, err) retryDuration, tick := integration.WaitForAndTickWithMaxDuration(ctx, time.Minute)
require.EventuallyWithT(t, func(tt *assert.CollectT) {
_, err := Client.GetSession(ctx, &session.GetSessionRequest{SessionId: id})
if !assert.NoError(tt, err) {
return
}
}, retryDuration, tick)
//terminate the session and test it does not work anymore //terminate the session and test it does not work anymore
_, err = Client.DeleteSession(CTX, &session.DeleteSessionRequest{ _, err := Client.DeleteSession(CTX, &session.DeleteSessionRequest{
SessionId: id, SessionId: id,
SessionToken: gu.Ptr(token), SessionToken: gu.Ptr(token),
}) })
require.NoError(t, err) require.NoError(t, err)
ctx = integration.WithAuthorizationToken(context.Background(), token) ctx = integration.WithAuthorizationToken(context.Background(), token)
_, err = Client.GetSession(ctx, &session.GetSessionRequest{SessionId: id}) retryDuration, tick = integration.WaitForAndTickWithMaxDuration(ctx, time.Minute)
require.Error(t, err) require.EventuallyWithT(t, func(tt *assert.CollectT) {
_, err := Client.GetSession(ctx, &session.GetSessionRequest{SessionId: id})
if !assert.Error(tt, err) {
return
}
}, retryDuration, tick)
} }
func Test_ZITADEL_API_session_expired(t *testing.T) { func Test_ZITADEL_API_session_expired(t *testing.T) {
@ -987,8 +923,13 @@ func Test_ZITADEL_API_session_expired(t *testing.T) {
// test session token works // test session token works
ctx := integration.WithAuthorizationToken(context.Background(), token) ctx := integration.WithAuthorizationToken(context.Background(), token)
_, err := Client.GetSession(ctx, &session.GetSessionRequest{SessionId: id}) retryDuration, tick := integration.WaitForAndTickWithMaxDuration(ctx, time.Minute)
require.NoError(t, err) require.EventuallyWithT(t, func(tt *assert.CollectT) {
_, err := Client.GetSession(ctx, &session.GetSessionRequest{SessionId: id})
if !assert.NoError(tt, err) {
return
}
}, retryDuration, tick)
// ensure session expires and does not work anymore // ensure session expires and does not work anymore
time.Sleep(20 * time.Second) time.Sleep(20 * time.Second)

View File

@ -0,0 +1,262 @@
package session
import (
"context"
"time"
"github.com/muhlemmer/gu"
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/api/grpc/object/v2"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/query"
"github.com/zitadel/zitadel/internal/zerrors"
objpb "github.com/zitadel/zitadel/pkg/grpc/object"
"github.com/zitadel/zitadel/pkg/grpc/session/v2"
)
var (
timestampComparisons = map[objpb.TimestampQueryMethod]query.TimestampComparison{
objpb.TimestampQueryMethod_TIMESTAMP_QUERY_METHOD_EQUALS: query.TimestampEquals,
objpb.TimestampQueryMethod_TIMESTAMP_QUERY_METHOD_GREATER: query.TimestampGreater,
objpb.TimestampQueryMethod_TIMESTAMP_QUERY_METHOD_GREATER_OR_EQUALS: query.TimestampGreaterOrEquals,
objpb.TimestampQueryMethod_TIMESTAMP_QUERY_METHOD_LESS: query.TimestampLess,
objpb.TimestampQueryMethod_TIMESTAMP_QUERY_METHOD_LESS_OR_EQUALS: query.TimestampLessOrEquals,
}
)
func (s *Server) GetSession(ctx context.Context, req *session.GetSessionRequest) (*session.GetSessionResponse, error) {
res, err := s.query.SessionByID(ctx, true, req.GetSessionId(), req.GetSessionToken(), s.checkPermission)
if err != nil {
return nil, err
}
return &session.GetSessionResponse{
Session: sessionToPb(res),
}, nil
}
func (s *Server) ListSessions(ctx context.Context, req *session.ListSessionsRequest) (*session.ListSessionsResponse, error) {
queries, err := listSessionsRequestToQuery(ctx, req)
if err != nil {
return nil, err
}
sessions, err := s.query.SearchSessions(ctx, queries, s.checkPermission)
if err != nil {
return nil, err
}
return &session.ListSessionsResponse{
Details: object.ToListDetails(sessions.SearchResponse),
Sessions: sessionsToPb(sessions.Sessions),
}, nil
}
func listSessionsRequestToQuery(ctx context.Context, req *session.ListSessionsRequest) (*query.SessionsSearchQueries, error) {
offset, limit, asc := object.ListQueryToQuery(req.Query)
queries, err := sessionQueriesToQuery(ctx, req.GetQueries())
if err != nil {
return nil, err
}
return &query.SessionsSearchQueries{
SearchRequest: query.SearchRequest{
Offset: offset,
Limit: limit,
Asc: asc,
SortingColumn: fieldNameToSessionColumn(req.GetSortingColumn()),
},
Queries: queries,
}, nil
}
func sessionQueriesToQuery(ctx context.Context, queries []*session.SearchQuery) (_ []query.SearchQuery, err error) {
q := make([]query.SearchQuery, len(queries))
for i, v := range queries {
q[i], err = sessionQueryToQuery(ctx, v)
if err != nil {
return nil, err
}
}
return q, nil
}
func sessionQueryToQuery(ctx context.Context, sq *session.SearchQuery) (query.SearchQuery, error) {
switch q := sq.Query.(type) {
case *session.SearchQuery_IdsQuery:
return idsQueryToQuery(q.IdsQuery)
case *session.SearchQuery_UserIdQuery:
return query.NewUserIDSearchQuery(q.UserIdQuery.GetId())
case *session.SearchQuery_CreationDateQuery:
return creationDateQueryToQuery(q.CreationDateQuery)
case *session.SearchQuery_CreatorQuery:
if q.CreatorQuery != nil && q.CreatorQuery.Id != nil {
if q.CreatorQuery.GetId() != "" {
return query.NewSessionCreatorSearchQuery(q.CreatorQuery.GetId())
}
} else {
if userID := authz.GetCtxData(ctx).UserID; userID != "" {
return query.NewSessionCreatorSearchQuery(userID)
}
}
return nil, zerrors.ThrowInvalidArgument(nil, "GRPC-x8n24uh", "List.Query.Invalid")
case *session.SearchQuery_UserAgentQuery:
if q.UserAgentQuery != nil && q.UserAgentQuery.FingerprintId != nil {
if *q.UserAgentQuery.FingerprintId != "" {
return query.NewSessionUserAgentFingerprintIDSearchQuery(q.UserAgentQuery.GetFingerprintId())
}
} else {
if agentID := authz.GetCtxData(ctx).AgentID; agentID != "" {
return query.NewSessionUserAgentFingerprintIDSearchQuery(agentID)
}
}
return nil, zerrors.ThrowInvalidArgument(nil, "GRPC-x8n23uh", "List.Query.Invalid")
default:
return nil, zerrors.ThrowInvalidArgument(nil, "GRPC-Sfefs", "List.Query.Invalid")
}
}
func idsQueryToQuery(q *session.IDsQuery) (query.SearchQuery, error) {
return query.NewSessionIDsSearchQuery(q.Ids)
}
func creationDateQueryToQuery(q *session.CreationDateQuery) (query.SearchQuery, error) {
comparison := timestampComparisons[q.GetMethod()]
return query.NewCreationDateQuery(q.GetCreationDate().AsTime(), comparison)
}
func fieldNameToSessionColumn(field session.SessionFieldName) query.Column {
switch field {
case session.SessionFieldName_SESSION_FIELD_NAME_CREATION_DATE:
return query.SessionColumnCreationDate
case session.SessionFieldName_SESSION_FIELD_NAME_UNSPECIFIED:
return query.Column{}
default:
return query.Column{}
}
}
func sessionsToPb(sessions []*query.Session) []*session.Session {
s := make([]*session.Session, len(sessions))
for i, session := range sessions {
s[i] = sessionToPb(session)
}
return s
}
func sessionToPb(s *query.Session) *session.Session {
return &session.Session{
Id: s.ID,
CreationDate: timestamppb.New(s.CreationDate),
ChangeDate: timestamppb.New(s.ChangeDate),
Sequence: s.Sequence,
Factors: factorsToPb(s),
Metadata: s.Metadata,
UserAgent: userAgentToPb(s.UserAgent),
ExpirationDate: expirationToPb(s.Expiration),
}
}
func userAgentToPb(ua domain.UserAgent) *session.UserAgent {
if ua.IsEmpty() {
return nil
}
out := &session.UserAgent{
FingerprintId: ua.FingerprintID,
Description: ua.Description,
}
if ua.IP != nil {
out.Ip = gu.Ptr(ua.IP.String())
}
if ua.Header == nil {
return out
}
out.Header = make(map[string]*session.UserAgent_HeaderValues, len(ua.Header))
for k, v := range ua.Header {
out.Header[k] = &session.UserAgent_HeaderValues{
Values: v,
}
}
return out
}
func expirationToPb(expiration time.Time) *timestamppb.Timestamp {
if expiration.IsZero() {
return nil
}
return timestamppb.New(expiration)
}
func factorsToPb(s *query.Session) *session.Factors {
user := userFactorToPb(s.UserFactor)
if user == nil {
return nil
}
return &session.Factors{
User: user,
Password: passwordFactorToPb(s.PasswordFactor),
WebAuthN: webAuthNFactorToPb(s.WebAuthNFactor),
Intent: intentFactorToPb(s.IntentFactor),
Totp: totpFactorToPb(s.TOTPFactor),
OtpSms: otpFactorToPb(s.OTPSMSFactor),
OtpEmail: otpFactorToPb(s.OTPEmailFactor),
}
}
func passwordFactorToPb(factor query.SessionPasswordFactor) *session.PasswordFactor {
if factor.PasswordCheckedAt.IsZero() {
return nil
}
return &session.PasswordFactor{
VerifiedAt: timestamppb.New(factor.PasswordCheckedAt),
}
}
func intentFactorToPb(factor query.SessionIntentFactor) *session.IntentFactor {
if factor.IntentCheckedAt.IsZero() {
return nil
}
return &session.IntentFactor{
VerifiedAt: timestamppb.New(factor.IntentCheckedAt),
}
}
func webAuthNFactorToPb(factor query.SessionWebAuthNFactor) *session.WebAuthNFactor {
if factor.WebAuthNCheckedAt.IsZero() {
return nil
}
return &session.WebAuthNFactor{
VerifiedAt: timestamppb.New(factor.WebAuthNCheckedAt),
UserVerified: factor.UserVerified,
}
}
func totpFactorToPb(factor query.SessionTOTPFactor) *session.TOTPFactor {
if factor.TOTPCheckedAt.IsZero() {
return nil
}
return &session.TOTPFactor{
VerifiedAt: timestamppb.New(factor.TOTPCheckedAt),
}
}
func otpFactorToPb(factor query.SessionOTPFactor) *session.OTPFactor {
if factor.OTPCheckedAt.IsZero() {
return nil
}
return &session.OTPFactor{
VerifiedAt: timestamppb.New(factor.OTPCheckedAt),
}
}
func userFactorToPb(factor query.SessionUserFactor) *session.UserFactor {
if factor.UserID == "" || factor.UserCheckedAt.IsZero() {
return nil
}
return &session.UserFactor{
VerifiedAt: timestamppb.New(factor.UserCheckedAt),
Id: factor.UserID,
LoginName: factor.LoginName,
DisplayName: factor.DisplayName,
OrganizationId: factor.ResourceOwner,
}
}

View File

@ -6,6 +6,7 @@ import (
"github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/api/grpc/server" "github.com/zitadel/zitadel/internal/api/grpc/server"
"github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/command"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/internal/query"
"github.com/zitadel/zitadel/pkg/grpc/session/v2" "github.com/zitadel/zitadel/pkg/grpc/session/v2"
) )
@ -16,6 +17,8 @@ type Server struct {
session.UnimplementedSessionServiceServer session.UnimplementedSessionServiceServer
command *command.Commands command *command.Commands
query *query.Queries query *query.Queries
checkPermission domain.PermissionCheck
} }
type Config struct{} type Config struct{}
@ -23,10 +26,12 @@ type Config struct{}
func CreateServer( func CreateServer(
command *command.Commands, command *command.Commands,
query *query.Queries, query *query.Queries,
checkPermission domain.PermissionCheck,
) *Server { ) *Server {
return &Server{ return &Server{
command: command, command: command,
query: query, query: query,
checkPermission: checkPermission,
} }
} }

View File

@ -6,56 +6,17 @@ import (
"net/http" "net/http"
"time" "time"
"github.com/muhlemmer/gu"
"golang.org/x/text/language" "golang.org/x/text/language"
"google.golang.org/protobuf/types/known/structpb" "google.golang.org/protobuf/types/known/structpb"
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/api/grpc/object/v2" "github.com/zitadel/zitadel/internal/api/grpc/object/v2"
"github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/command"
"github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/internal/query"
"github.com/zitadel/zitadel/internal/zerrors" "github.com/zitadel/zitadel/internal/zerrors"
objpb "github.com/zitadel/zitadel/pkg/grpc/object"
"github.com/zitadel/zitadel/pkg/grpc/session/v2" "github.com/zitadel/zitadel/pkg/grpc/session/v2"
) )
var (
timestampComparisons = map[objpb.TimestampQueryMethod]query.TimestampComparison{
objpb.TimestampQueryMethod_TIMESTAMP_QUERY_METHOD_EQUALS: query.TimestampEquals,
objpb.TimestampQueryMethod_TIMESTAMP_QUERY_METHOD_GREATER: query.TimestampGreater,
objpb.TimestampQueryMethod_TIMESTAMP_QUERY_METHOD_GREATER_OR_EQUALS: query.TimestampGreaterOrEquals,
objpb.TimestampQueryMethod_TIMESTAMP_QUERY_METHOD_LESS: query.TimestampLess,
objpb.TimestampQueryMethod_TIMESTAMP_QUERY_METHOD_LESS_OR_EQUALS: query.TimestampLessOrEquals,
}
)
func (s *Server) GetSession(ctx context.Context, req *session.GetSessionRequest) (*session.GetSessionResponse, error) {
res, err := s.query.SessionByID(ctx, true, req.GetSessionId(), req.GetSessionToken())
if err != nil {
return nil, err
}
return &session.GetSessionResponse{
Session: sessionToPb(res),
}, nil
}
func (s *Server) ListSessions(ctx context.Context, req *session.ListSessionsRequest) (*session.ListSessionsResponse, error) {
queries, err := listSessionsRequestToQuery(ctx, req)
if err != nil {
return nil, err
}
sessions, err := s.query.SearchSessions(ctx, queries)
if err != nil {
return nil, err
}
return &session.ListSessionsResponse{
Details: object.ToListDetails(sessions.SearchResponse),
Sessions: sessionsToPb(sessions.Sessions),
}, nil
}
func (s *Server) CreateSession(ctx context.Context, req *session.CreateSessionRequest) (*session.CreateSessionResponse, error) { func (s *Server) CreateSession(ctx context.Context, req *session.CreateSessionRequest) (*session.CreateSessionResponse, error) {
checks, metadata, userAgent, lifetime, err := s.createSessionRequestToCommand(ctx, req) checks, metadata, userAgent, lifetime, err := s.createSessionRequestToCommand(ctx, req)
if err != nil { if err != nil {
@ -110,197 +71,6 @@ func (s *Server) DeleteSession(ctx context.Context, req *session.DeleteSessionRe
}, nil }, nil
} }
func sessionsToPb(sessions []*query.Session) []*session.Session {
s := make([]*session.Session, len(sessions))
for i, session := range sessions {
s[i] = sessionToPb(session)
}
return s
}
func sessionToPb(s *query.Session) *session.Session {
return &session.Session{
Id: s.ID,
CreationDate: timestamppb.New(s.CreationDate),
ChangeDate: timestamppb.New(s.ChangeDate),
Sequence: s.Sequence,
Factors: factorsToPb(s),
Metadata: s.Metadata,
UserAgent: userAgentToPb(s.UserAgent),
ExpirationDate: expirationToPb(s.Expiration),
}
}
func userAgentToPb(ua domain.UserAgent) *session.UserAgent {
if ua.IsEmpty() {
return nil
}
out := &session.UserAgent{
FingerprintId: ua.FingerprintID,
Description: ua.Description,
}
if ua.IP != nil {
out.Ip = gu.Ptr(ua.IP.String())
}
if ua.Header == nil {
return out
}
out.Header = make(map[string]*session.UserAgent_HeaderValues, len(ua.Header))
for k, v := range ua.Header {
out.Header[k] = &session.UserAgent_HeaderValues{
Values: v,
}
}
return out
}
func expirationToPb(expiration time.Time) *timestamppb.Timestamp {
if expiration.IsZero() {
return nil
}
return timestamppb.New(expiration)
}
func factorsToPb(s *query.Session) *session.Factors {
user := userFactorToPb(s.UserFactor)
if user == nil {
return nil
}
return &session.Factors{
User: user,
Password: passwordFactorToPb(s.PasswordFactor),
WebAuthN: webAuthNFactorToPb(s.WebAuthNFactor),
Intent: intentFactorToPb(s.IntentFactor),
Totp: totpFactorToPb(s.TOTPFactor),
OtpSms: otpFactorToPb(s.OTPSMSFactor),
OtpEmail: otpFactorToPb(s.OTPEmailFactor),
}
}
func passwordFactorToPb(factor query.SessionPasswordFactor) *session.PasswordFactor {
if factor.PasswordCheckedAt.IsZero() {
return nil
}
return &session.PasswordFactor{
VerifiedAt: timestamppb.New(factor.PasswordCheckedAt),
}
}
func intentFactorToPb(factor query.SessionIntentFactor) *session.IntentFactor {
if factor.IntentCheckedAt.IsZero() {
return nil
}
return &session.IntentFactor{
VerifiedAt: timestamppb.New(factor.IntentCheckedAt),
}
}
func webAuthNFactorToPb(factor query.SessionWebAuthNFactor) *session.WebAuthNFactor {
if factor.WebAuthNCheckedAt.IsZero() {
return nil
}
return &session.WebAuthNFactor{
VerifiedAt: timestamppb.New(factor.WebAuthNCheckedAt),
UserVerified: factor.UserVerified,
}
}
func totpFactorToPb(factor query.SessionTOTPFactor) *session.TOTPFactor {
if factor.TOTPCheckedAt.IsZero() {
return nil
}
return &session.TOTPFactor{
VerifiedAt: timestamppb.New(factor.TOTPCheckedAt),
}
}
func otpFactorToPb(factor query.SessionOTPFactor) *session.OTPFactor {
if factor.OTPCheckedAt.IsZero() {
return nil
}
return &session.OTPFactor{
VerifiedAt: timestamppb.New(factor.OTPCheckedAt),
}
}
func userFactorToPb(factor query.SessionUserFactor) *session.UserFactor {
if factor.UserID == "" || factor.UserCheckedAt.IsZero() {
return nil
}
return &session.UserFactor{
VerifiedAt: timestamppb.New(factor.UserCheckedAt),
Id: factor.UserID,
LoginName: factor.LoginName,
DisplayName: factor.DisplayName,
OrganizationId: factor.ResourceOwner,
}
}
func listSessionsRequestToQuery(ctx context.Context, req *session.ListSessionsRequest) (*query.SessionsSearchQueries, error) {
offset, limit, asc := object.ListQueryToQuery(req.Query)
queries, err := sessionQueriesToQuery(ctx, req.GetQueries())
if err != nil {
return nil, err
}
return &query.SessionsSearchQueries{
SearchRequest: query.SearchRequest{
Offset: offset,
Limit: limit,
Asc: asc,
SortingColumn: fieldNameToSessionColumn(req.GetSortingColumn()),
},
Queries: queries,
}, nil
}
func sessionQueriesToQuery(ctx context.Context, queries []*session.SearchQuery) (_ []query.SearchQuery, err error) {
q := make([]query.SearchQuery, len(queries)+1)
for i, v := range queries {
q[i], err = sessionQueryToQuery(v)
if err != nil {
return nil, err
}
}
creatorQuery, err := query.NewSessionCreatorSearchQuery(authz.GetCtxData(ctx).UserID)
if err != nil {
return nil, err
}
q[len(queries)] = creatorQuery
return q, nil
}
func sessionQueryToQuery(sq *session.SearchQuery) (query.SearchQuery, error) {
switch q := sq.Query.(type) {
case *session.SearchQuery_IdsQuery:
return idsQueryToQuery(q.IdsQuery)
case *session.SearchQuery_UserIdQuery:
return query.NewUserIDSearchQuery(q.UserIdQuery.GetId())
case *session.SearchQuery_CreationDateQuery:
return creationDateQueryToQuery(q.CreationDateQuery)
default:
return nil, zerrors.ThrowInvalidArgument(nil, "GRPC-Sfefs", "List.Query.Invalid")
}
}
func idsQueryToQuery(q *session.IDsQuery) (query.SearchQuery, error) {
return query.NewSessionIDsSearchQuery(q.Ids)
}
func creationDateQueryToQuery(q *session.CreationDateQuery) (query.SearchQuery, error) {
comparison := timestampComparisons[q.GetMethod()]
return query.NewCreationDateQuery(q.GetCreationDate().AsTime(), comparison)
}
func fieldNameToSessionColumn(field session.SessionFieldName) query.Column {
switch field {
case session.SessionFieldName_SESSION_FIELD_NAME_CREATION_DATE:
return query.SessionColumnCreationDate
default:
return query.Column{}
}
}
func (s *Server) createSessionRequestToCommand(ctx context.Context, req *session.CreateSessionRequest) ([]command.SessionCommand, map[string][]byte, *domain.UserAgent, time.Duration, error) { func (s *Server) createSessionRequestToCommand(ctx context.Context, req *session.CreateSessionRequest) ([]command.SessionCommand, map[string][]byte, *domain.UserAgent, time.Duration, error) {
checks, err := s.checksToCommand(ctx, req.Checks) checks, err := s.checksToCommand(ctx, req.Checks)
if err != nil { if err != nil {

View File

@ -339,9 +339,7 @@ func Test_listSessionsRequestToQuery(t *testing.T) {
Limit: 0, Limit: 0,
Asc: false, Asc: false,
}, },
Queries: []query.SearchQuery{ Queries: []query.SearchQuery{},
mustNewTextQuery(t, query.SessionColumnCreator, "789", query.TextEquals),
},
}, },
}, },
{ {
@ -359,15 +357,13 @@ func Test_listSessionsRequestToQuery(t *testing.T) {
SortingColumn: query.SessionColumnCreationDate, SortingColumn: query.SessionColumnCreationDate,
Asc: false, Asc: false,
}, },
Queries: []query.SearchQuery{ Queries: []query.SearchQuery{},
mustNewTextQuery(t, query.SessionColumnCreator, "789", query.TextEquals),
},
}, },
}, },
{ {
name: "with list query and sessions", name: "with list query and sessions",
args: args{ args: args{
ctx: authz.NewMockContext("123", "456", "789"), ctx: authz.SetCtxData(context.Background(), authz.CtxData{AgentID: "agent", UserID: "789"}),
req: &session.ListSessionsRequest{ req: &session.ListSessionsRequest{
Query: &object.ListQuery{ Query: &object.ListQuery{
Offset: 10, Offset: 10,
@ -396,6 +392,12 @@ func Test_listSessionsRequestToQuery(t *testing.T) {
Method: objpb.TimestampQueryMethod_TIMESTAMP_QUERY_METHOD_GREATER, Method: objpb.TimestampQueryMethod_TIMESTAMP_QUERY_METHOD_GREATER,
}, },
}}, }},
{Query: &session.SearchQuery_CreatorQuery{
CreatorQuery: &session.CreatorQuery{},
}},
{Query: &session.SearchQuery_UserAgentQuery{
UserAgentQuery: &session.UserAgentQuery{},
}},
}, },
}, },
}, },
@ -411,6 +413,7 @@ func Test_listSessionsRequestToQuery(t *testing.T) {
mustNewTextQuery(t, query.SessionColumnUserID, "10", query.TextEquals), mustNewTextQuery(t, query.SessionColumnUserID, "10", query.TextEquals),
mustNewTimestampQuery(t, query.SessionColumnCreationDate, creationDate, query.TimestampGreater), mustNewTimestampQuery(t, query.SessionColumnCreationDate, creationDate, query.TimestampGreater),
mustNewTextQuery(t, query.SessionColumnCreator, "789", query.TextEquals), mustNewTextQuery(t, query.SessionColumnCreator, "789", query.TextEquals),
mustNewTextQuery(t, query.SessionColumnUserAgentFingerprintID, "agent", query.TextEquals),
}, },
}, },
}, },
@ -458,13 +461,11 @@ func Test_sessionQueriesToQuery(t *testing.T) {
wantErr error wantErr error
}{ }{
{ {
name: "creator only", name: "no queries",
args: args{ args: args{
ctx: authz.NewMockContext("123", "456", "789"), ctx: authz.NewMockContext("123", "456", "789"),
}, },
want: []query.SearchQuery{ want: []query.SearchQuery{},
mustNewTextQuery(t, query.SessionColumnCreator, "789", query.TextEquals),
},
}, },
{ {
name: "invalid argument", name: "invalid argument",
@ -491,6 +492,9 @@ func Test_sessionQueriesToQuery(t *testing.T) {
Ids: []string{"4", "5", "6"}, Ids: []string{"4", "5", "6"},
}, },
}}, }},
{Query: &session.SearchQuery_CreatorQuery{
CreatorQuery: &session.CreatorQuery{},
}},
}, },
}, },
want: []query.SearchQuery{ want: []query.SearchQuery{
@ -511,6 +515,7 @@ func Test_sessionQueriesToQuery(t *testing.T) {
func Test_sessionQueryToQuery(t *testing.T) { func Test_sessionQueryToQuery(t *testing.T) {
type args struct { type args struct {
ctx context.Context
query *session.SearchQuery query *session.SearchQuery
} }
tests := []struct { tests := []struct {
@ -521,60 +526,158 @@ func Test_sessionQueryToQuery(t *testing.T) {
}{ }{
{ {
name: "invalid argument", name: "invalid argument",
args: args{&session.SearchQuery{ args: args{
Query: nil, context.Background(),
}}, &session.SearchQuery{
Query: nil,
}},
wantErr: zerrors.ThrowInvalidArgument(nil, "GRPC-Sfefs", "List.Query.Invalid"), wantErr: zerrors.ThrowInvalidArgument(nil, "GRPC-Sfefs", "List.Query.Invalid"),
}, },
{ {
name: "ids query", name: "ids query",
args: args{&session.SearchQuery{ args: args{
Query: &session.SearchQuery_IdsQuery{ context.Background(),
IdsQuery: &session.IDsQuery{ &session.SearchQuery{
Ids: []string{"1", "2", "3"}, Query: &session.SearchQuery_IdsQuery{
IdsQuery: &session.IDsQuery{
Ids: []string{"1", "2", "3"},
},
}, },
}, }},
}},
want: mustNewListQuery(t, query.SessionColumnID, []interface{}{"1", "2", "3"}, query.ListIn), want: mustNewListQuery(t, query.SessionColumnID, []interface{}{"1", "2", "3"}, query.ListIn),
}, },
{ {
name: "user id query", name: "user id query",
args: args{&session.SearchQuery{ args: args{
Query: &session.SearchQuery_UserIdQuery{ context.Background(),
UserIdQuery: &session.UserIDQuery{ &session.SearchQuery{
Id: "10", Query: &session.SearchQuery_UserIdQuery{
UserIdQuery: &session.UserIDQuery{
Id: "10",
},
}, },
}, }},
}},
want: mustNewTextQuery(t, query.SessionColumnUserID, "10", query.TextEquals), want: mustNewTextQuery(t, query.SessionColumnUserID, "10", query.TextEquals),
}, },
{ {
name: "creation date query", name: "creation date query",
args: args{&session.SearchQuery{ args: args{
Query: &session.SearchQuery_CreationDateQuery{ context.Background(),
CreationDateQuery: &session.CreationDateQuery{ &session.SearchQuery{
CreationDate: timestamppb.New(creationDate), Query: &session.SearchQuery_CreationDateQuery{
Method: objpb.TimestampQueryMethod_TIMESTAMP_QUERY_METHOD_LESS, CreationDateQuery: &session.CreationDateQuery{
CreationDate: timestamppb.New(creationDate),
Method: objpb.TimestampQueryMethod_TIMESTAMP_QUERY_METHOD_LESS,
},
}, },
}, }},
}},
want: mustNewTimestampQuery(t, query.SessionColumnCreationDate, creationDate, query.TimestampLess), want: mustNewTimestampQuery(t, query.SessionColumnCreationDate, creationDate, query.TimestampLess),
}, },
{ {
name: "creation date query with default method", name: "creation date query with default method",
args: args{&session.SearchQuery{ args: args{
Query: &session.SearchQuery_CreationDateQuery{ context.Background(),
CreationDateQuery: &session.CreationDateQuery{ &session.SearchQuery{
CreationDate: timestamppb.New(creationDate), Query: &session.SearchQuery_CreationDateQuery{
CreationDateQuery: &session.CreationDateQuery{
CreationDate: timestamppb.New(creationDate),
},
}, },
}, }},
}},
want: mustNewTimestampQuery(t, query.SessionColumnCreationDate, creationDate, query.TimestampEquals), want: mustNewTimestampQuery(t, query.SessionColumnCreationDate, creationDate, query.TimestampEquals),
}, },
{
name: "own creator",
args: args{
authz.SetCtxData(context.Background(), authz.CtxData{UserID: "creator"}),
&session.SearchQuery{
Query: &session.SearchQuery_CreatorQuery{
CreatorQuery: &session.CreatorQuery{},
},
}},
want: mustNewTextQuery(t, query.SessionColumnCreator, "creator", query.TextEquals),
},
{
name: "empty own creator, error",
args: args{
authz.SetCtxData(context.Background(), authz.CtxData{UserID: ""}),
&session.SearchQuery{
Query: &session.SearchQuery_CreatorQuery{
CreatorQuery: &session.CreatorQuery{},
},
}},
wantErr: zerrors.ThrowInvalidArgument(nil, "GRPC-x8n24uh", "List.Query.Invalid"),
},
{
name: "creator",
args: args{
authz.SetCtxData(context.Background(), authz.CtxData{UserID: "creator1"}),
&session.SearchQuery{
Query: &session.SearchQuery_CreatorQuery{
CreatorQuery: &session.CreatorQuery{Id: gu.Ptr("creator2")},
},
}},
want: mustNewTextQuery(t, query.SessionColumnCreator, "creator2", query.TextEquals),
},
{
name: "empty creator, error",
args: args{
authz.SetCtxData(context.Background(), authz.CtxData{UserID: "creator1"}),
&session.SearchQuery{
Query: &session.SearchQuery_CreatorQuery{
CreatorQuery: &session.CreatorQuery{Id: gu.Ptr("")},
},
}},
wantErr: zerrors.ThrowInvalidArgument(nil, "GRPC-x8n24uh", "List.Query.Invalid"),
},
{
name: "empty own useragent, error",
args: args{
authz.SetCtxData(context.Background(), authz.CtxData{AgentID: ""}),
&session.SearchQuery{
Query: &session.SearchQuery_UserAgentQuery{
UserAgentQuery: &session.UserAgentQuery{},
},
}},
wantErr: zerrors.ThrowInvalidArgument(nil, "GRPC-x8n23uh", "List.Query.Invalid"),
},
{
name: "own useragent",
args: args{
authz.SetCtxData(context.Background(), authz.CtxData{AgentID: "agent"}),
&session.SearchQuery{
Query: &session.SearchQuery_UserAgentQuery{
UserAgentQuery: &session.UserAgentQuery{},
},
}},
want: mustNewTextQuery(t, query.SessionColumnUserAgentFingerprintID, "agent", query.TextEquals),
},
{
name: "empty useragent, error",
args: args{
authz.SetCtxData(context.Background(), authz.CtxData{AgentID: "agent"}),
&session.SearchQuery{
Query: &session.SearchQuery_UserAgentQuery{
UserAgentQuery: &session.UserAgentQuery{FingerprintId: gu.Ptr("")},
},
}},
wantErr: zerrors.ThrowInvalidArgument(nil, "GRPC-x8n23uh", "List.Query.Invalid"),
},
{
name: "useragent",
args: args{
authz.SetCtxData(context.Background(), authz.CtxData{AgentID: "agent1"}),
&session.SearchQuery{
Query: &session.SearchQuery_UserAgentQuery{
UserAgentQuery: &session.UserAgentQuery{FingerprintId: gu.Ptr("agent2")},
},
}},
want: mustNewTextQuery(t, query.SessionColumnUserAgentFingerprintID, "agent2", query.TextEquals),
},
} }
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 := sessionQueryToQuery(tt.args.query) got, err := sessionQueryToQuery(tt.args.ctx, tt.args.query)
require.ErrorIs(t, err, tt.wantErr) require.ErrorIs(t, err, tt.wantErr)
assert.Equal(t, tt.want, got) assert.Equal(t, tt.want, got)
}) })

View File

@ -0,0 +1,512 @@
//go:build integration
package session_test
import (
"context"
"testing"
"time"
"github.com/golang/protobuf/ptypes/timestamp"
"github.com/muhlemmer/gu"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/types/known/durationpb"
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/zitadel/zitadel/internal/integration"
object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta"
session "github.com/zitadel/zitadel/pkg/grpc/session/v2beta"
)
func TestServer_GetSession(t *testing.T) {
type args struct {
ctx context.Context
req *session.GetSessionRequest
dep func(ctx context.Context, t *testing.T, request *session.GetSessionRequest) uint64
}
tests := []struct {
name string
args args
want *session.GetSessionResponse
wantFactors []wantFactor
wantExpirationWindow time.Duration
wantErr bool
}{
{
name: "get session, no id provided",
args: args{
CTX,
&session.GetSessionRequest{
SessionId: "",
},
nil,
},
wantErr: true,
},
{
name: "get session, not found",
args: args{
CTX,
&session.GetSessionRequest{
SessionId: "unknown",
},
nil,
},
wantErr: true,
},
{
name: "get session, no permission",
args: args{
UserCTX,
&session.GetSessionRequest{},
func(ctx context.Context, t *testing.T, request *session.GetSessionRequest) uint64 {
resp, err := Client.CreateSession(CTX, &session.CreateSessionRequest{})
require.NoError(t, err)
request.SessionId = resp.SessionId
return resp.GetDetails().GetSequence()
},
},
wantErr: true,
},
{
name: "get session, permission, ok",
args: args{
CTX,
&session.GetSessionRequest{},
func(ctx context.Context, t *testing.T, request *session.GetSessionRequest) uint64 {
resp, err := Client.CreateSession(CTX, &session.CreateSessionRequest{})
require.NoError(t, err)
request.SessionId = resp.SessionId
return resp.GetDetails().GetSequence()
},
},
want: &session.GetSessionResponse{
Session: &session.Session{},
},
},
{
name: "get session, token, ok",
args: args{
UserCTX,
&session.GetSessionRequest{},
func(ctx context.Context, t *testing.T, request *session.GetSessionRequest) uint64 {
resp, err := Client.CreateSession(CTX, &session.CreateSessionRequest{})
require.NoError(t, err)
request.SessionId = resp.SessionId
request.SessionToken = gu.Ptr(resp.SessionToken)
return resp.GetDetails().GetSequence()
},
},
want: &session.GetSessionResponse{
Session: &session.Session{},
},
},
{
name: "get session, user agent, ok",
args: args{
UserCTX,
&session.GetSessionRequest{},
func(ctx context.Context, t *testing.T, request *session.GetSessionRequest) uint64 {
resp, err := Client.CreateSession(CTX, &session.CreateSessionRequest{
UserAgent: &session.UserAgent{
FingerprintId: gu.Ptr("fingerPrintID"),
Ip: gu.Ptr("1.2.3.4"),
Description: gu.Ptr("Description"),
Header: map[string]*session.UserAgent_HeaderValues{
"foo": {Values: []string{"foo", "bar"}},
},
},
},
)
require.NoError(t, err)
request.SessionId = resp.SessionId
request.SessionToken = gu.Ptr(resp.SessionToken)
return resp.GetDetails().GetSequence()
},
},
want: &session.GetSessionResponse{
Session: &session.Session{
UserAgent: &session.UserAgent{
FingerprintId: gu.Ptr("fingerPrintID"),
Ip: gu.Ptr("1.2.3.4"),
Description: gu.Ptr("Description"),
Header: map[string]*session.UserAgent_HeaderValues{
"foo": {Values: []string{"foo", "bar"}},
},
},
},
},
},
{
name: "get session, lifetime, ok",
args: args{
UserCTX,
&session.GetSessionRequest{},
func(ctx context.Context, t *testing.T, request *session.GetSessionRequest) uint64 {
resp, err := Client.CreateSession(CTX, &session.CreateSessionRequest{
Lifetime: durationpb.New(5 * time.Minute),
},
)
require.NoError(t, err)
request.SessionId = resp.SessionId
request.SessionToken = gu.Ptr(resp.SessionToken)
return resp.GetDetails().GetSequence()
},
},
wantExpirationWindow: 5 * time.Minute,
want: &session.GetSessionResponse{
Session: &session.Session{},
},
},
{
name: "get session, metadata, ok",
args: args{
UserCTX,
&session.GetSessionRequest{},
func(ctx context.Context, t *testing.T, request *session.GetSessionRequest) uint64 {
resp, err := Client.CreateSession(CTX, &session.CreateSessionRequest{
Metadata: map[string][]byte{"foo": []byte("bar")},
},
)
require.NoError(t, err)
request.SessionId = resp.SessionId
request.SessionToken = gu.Ptr(resp.SessionToken)
return resp.GetDetails().GetSequence()
},
},
want: &session.GetSessionResponse{
Session: &session.Session{
Metadata: map[string][]byte{"foo": []byte("bar")},
},
},
},
{
name: "get session, user, ok",
args: args{
UserCTX,
&session.GetSessionRequest{},
func(ctx context.Context, t *testing.T, request *session.GetSessionRequest) uint64 {
resp, err := Client.CreateSession(CTX, &session.CreateSessionRequest{
Checks: &session.Checks{
User: &session.CheckUser{
Search: &session.CheckUser_UserId{
UserId: User.GetUserId(),
},
},
},
},
)
require.NoError(t, err)
request.SessionId = resp.SessionId
request.SessionToken = gu.Ptr(resp.SessionToken)
return resp.GetDetails().GetSequence()
},
},
wantFactors: []wantFactor{wantUserFactor},
want: &session.GetSessionResponse{
Session: &session.Session{},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var sequence uint64
if tt.args.dep != nil {
sequence = tt.args.dep(tt.args.ctx, t, tt.args.req)
}
retryDuration, tick := integration.WaitForAndTickWithMaxDuration(tt.args.ctx, time.Minute)
require.EventuallyWithT(t, func(ttt *assert.CollectT) {
got, err := Client.GetSession(tt.args.ctx, tt.args.req)
if tt.wantErr {
assert.Error(ttt, err)
return
}
if !assert.NoError(ttt, err) {
return
}
tt.want.Session.Id = tt.args.req.SessionId
tt.want.Session.Sequence = sequence
verifySession(ttt, got.GetSession(), tt.want.GetSession(), time.Minute, tt.wantExpirationWindow, User.GetUserId(), tt.wantFactors...)
}, retryDuration, tick)
})
}
}
type sessionAttr struct {
ID string
UserID string
UserAgent string
CreationDate *timestamp.Timestamp
ChangeDate *timestamppb.Timestamp
Details *object.Details
}
type sessionAttrs []*sessionAttr
func (u sessionAttrs) ids() []string {
ids := make([]string, len(u))
for i := range u {
ids[i] = u[i].ID
}
return ids
}
func createSessions(ctx context.Context, t *testing.T, count int, userID string, userAgent string, lifetime *durationpb.Duration, metadata map[string][]byte) sessionAttrs {
infos := make([]*sessionAttr, count)
for i := 0; i < count; i++ {
infos[i] = createSession(ctx, t, userID, userAgent, lifetime, metadata)
}
return infos
}
func createSession(ctx context.Context, t *testing.T, userID string, userAgent string, lifetime *durationpb.Duration, metadata map[string][]byte) *sessionAttr {
req := &session.CreateSessionRequest{}
if userID != "" {
req.Checks = &session.Checks{
User: &session.CheckUser{
Search: &session.CheckUser_UserId{
UserId: userID,
},
},
}
}
if userAgent != "" {
req.UserAgent = &session.UserAgent{
FingerprintId: gu.Ptr(userAgent),
Ip: gu.Ptr("1.2.3.4"),
Description: gu.Ptr("Description"),
Header: map[string]*session.UserAgent_HeaderValues{
"foo": {Values: []string{"foo", "bar"}},
},
}
}
if lifetime != nil {
req.Lifetime = lifetime
}
if metadata != nil {
req.Metadata = metadata
}
resp, err := Client.CreateSession(ctx, req)
require.NoError(t, err)
return &sessionAttr{
resp.GetSessionId(),
userID,
userAgent,
resp.GetDetails().GetChangeDate(),
resp.GetDetails().GetChangeDate(),
resp.GetDetails(),
}
}
func TestServer_ListSessions(t *testing.T) {
type args struct {
ctx context.Context
req *session.ListSessionsRequest
dep func(ctx context.Context, t *testing.T, request *session.ListSessionsRequest) []*sessionAttr
}
tests := []struct {
name string
args args
want *session.ListSessionsResponse
wantFactors []wantFactor
wantExpirationWindow time.Duration
wantErr bool
}{
{
name: "list sessions, not found",
args: args{
CTX,
&session.ListSessionsRequest{
Queries: []*session.SearchQuery{
{Query: &session.SearchQuery_IdsQuery{IdsQuery: &session.IDsQuery{Ids: []string{"unknown"}}}},
},
},
func(ctx context.Context, t *testing.T, request *session.ListSessionsRequest) []*sessionAttr {
return []*sessionAttr{}
},
},
want: &session.ListSessionsResponse{
Details: &object.ListDetails{
TotalResult: 0,
Timestamp: timestamppb.Now(),
},
Sessions: []*session.Session{},
},
},
{
name: "list sessions, wrong creator",
args: args{
UserCTX,
&session.ListSessionsRequest{},
func(ctx context.Context, t *testing.T, request *session.ListSessionsRequest) []*sessionAttr {
info := createSession(ctx, t, "", "", nil, nil)
request.Queries = append(request.Queries, &session.SearchQuery{Query: &session.SearchQuery_IdsQuery{IdsQuery: &session.IDsQuery{Ids: []string{info.ID}}}})
return []*sessionAttr{}
},
},
want: &session.ListSessionsResponse{
Details: &object.ListDetails{
TotalResult: 0,
Timestamp: timestamppb.Now(),
},
Sessions: []*session.Session{},
},
},
{
name: "list sessions, full, ok",
args: args{
CTX,
&session.ListSessionsRequest{},
func(ctx context.Context, t *testing.T, request *session.ListSessionsRequest) []*sessionAttr {
info := createSession(ctx, t, User.GetUserId(), "agent", durationpb.New(time.Minute*5), map[string][]byte{"key": []byte("value")})
request.Queries = append(request.Queries, &session.SearchQuery{Query: &session.SearchQuery_IdsQuery{IdsQuery: &session.IDsQuery{Ids: []string{info.ID}}}})
return []*sessionAttr{info}
},
},
wantExpirationWindow: time.Minute * 5,
wantFactors: []wantFactor{wantUserFactor},
want: &session.ListSessionsResponse{
Details: &object.ListDetails{
TotalResult: 1,
Timestamp: timestamppb.Now(),
},
Sessions: []*session.Session{
{
Metadata: map[string][]byte{"key": []byte("value")},
UserAgent: &session.UserAgent{
FingerprintId: gu.Ptr("agent"),
Ip: gu.Ptr("1.2.3.4"),
Description: gu.Ptr("Description"),
Header: map[string]*session.UserAgent_HeaderValues{
"foo": {Values: []string{"foo", "bar"}},
},
},
},
},
},
},
{
name: "list sessions, multiple, ok",
args: args{
CTX,
&session.ListSessionsRequest{},
func(ctx context.Context, t *testing.T, request *session.ListSessionsRequest) []*sessionAttr {
infos := createSessions(ctx, t, 3, User.GetUserId(), "agent", durationpb.New(time.Minute*5), map[string][]byte{"key": []byte("value")})
request.Queries = append(request.Queries, &session.SearchQuery{Query: &session.SearchQuery_IdsQuery{IdsQuery: &session.IDsQuery{Ids: infos.ids()}}})
return infos
},
},
wantExpirationWindow: time.Minute * 5,
wantFactors: []wantFactor{wantUserFactor},
want: &session.ListSessionsResponse{
Details: &object.ListDetails{
TotalResult: 3,
Timestamp: timestamppb.Now(),
},
Sessions: []*session.Session{
{
Metadata: map[string][]byte{"key": []byte("value")},
UserAgent: &session.UserAgent{
FingerprintId: gu.Ptr("agent"),
Ip: gu.Ptr("1.2.3.4"),
Description: gu.Ptr("Description"),
Header: map[string]*session.UserAgent_HeaderValues{
"foo": {Values: []string{"foo", "bar"}},
},
},
},
{
Metadata: map[string][]byte{"key": []byte("value")},
UserAgent: &session.UserAgent{
FingerprintId: gu.Ptr("agent"),
Ip: gu.Ptr("1.2.3.4"),
Description: gu.Ptr("Description"),
Header: map[string]*session.UserAgent_HeaderValues{
"foo": {Values: []string{"foo", "bar"}},
},
},
},
{
Metadata: map[string][]byte{"key": []byte("value")},
UserAgent: &session.UserAgent{
FingerprintId: gu.Ptr("agent"),
Ip: gu.Ptr("1.2.3.4"),
Description: gu.Ptr("Description"),
Header: map[string]*session.UserAgent_HeaderValues{
"foo": {Values: []string{"foo", "bar"}},
},
},
},
},
},
},
{
name: "list sessions, userid, ok",
args: args{
CTX,
&session.ListSessionsRequest{},
func(ctx context.Context, t *testing.T, request *session.ListSessionsRequest) []*sessionAttr {
createdUser := createFullUser(ctx)
info := createSession(ctx, t, createdUser.GetUserId(), "agent", durationpb.New(time.Minute*5), map[string][]byte{"key": []byte("value")})
request.Queries = append(request.Queries, &session.SearchQuery{Query: &session.SearchQuery_UserIdQuery{UserIdQuery: &session.UserIDQuery{Id: createdUser.GetUserId()}}})
return []*sessionAttr{info}
},
},
wantExpirationWindow: time.Minute * 5,
wantFactors: []wantFactor{wantUserFactor},
want: &session.ListSessionsResponse{
Details: &object.ListDetails{
TotalResult: 1,
Timestamp: timestamppb.Now(),
},
Sessions: []*session.Session{
{
Metadata: map[string][]byte{"key": []byte("value")},
UserAgent: &session.UserAgent{
FingerprintId: gu.Ptr("agent"),
Ip: gu.Ptr("1.2.3.4"),
Description: gu.Ptr("Description"),
Header: map[string]*session.UserAgent_HeaderValues{
"foo": {Values: []string{"foo", "bar"}},
},
},
},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
infos := tt.args.dep(CTX, t, tt.args.req)
retryDuration, tick := integration.WaitForAndTickWithMaxDuration(tt.args.ctx, time.Minute)
require.EventuallyWithT(t, func(ttt *assert.CollectT) {
got, err := Client.ListSessions(tt.args.ctx, tt.args.req)
if tt.wantErr {
assert.Error(ttt, err)
return
}
if !assert.NoError(ttt, err) {
return
}
if !assert.Equal(ttt, got.Details.TotalResult, tt.want.Details.TotalResult) || !assert.Len(ttt, got.Sessions, len(tt.want.Sessions)) {
return
}
for i := range infos {
tt.want.Sessions[i].Id = infos[i].ID
tt.want.Sessions[i].Sequence = infos[i].Details.GetSequence()
tt.want.Sessions[i].CreationDate = infos[i].Details.GetChangeDate()
tt.want.Sessions[i].ChangeDate = infos[i].Details.GetChangeDate()
verifySession(ttt, got.Sessions[i], tt.want.Sessions[i], time.Minute, tt.wantExpirationWindow, infos[i].UserID, tt.wantFactors...)
}
integration.AssertListDetails(ttt, tt.want, got)
}, retryDuration, tick)
})
}
}

View File

@ -0,0 +1,74 @@
//go:build integration
package session_test
import (
"context"
"os"
"testing"
"time"
"github.com/zitadel/logging"
"github.com/zitadel/zitadel/internal/integration"
session "github.com/zitadel/zitadel/pkg/grpc/session/v2beta"
"github.com/zitadel/zitadel/pkg/grpc/user/v2"
)
var (
CTX context.Context
IAMOwnerCTX context.Context
UserCTX context.Context
Instance *integration.Instance
Client session.SessionServiceClient
User *user.AddHumanUserResponse
DeactivatedUser *user.AddHumanUserResponse
LockedUser *user.AddHumanUserResponse
)
func TestMain(m *testing.M) {
os.Exit(func() int {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
defer cancel()
Instance = integration.NewInstance(ctx)
Client = Instance.Client.SessionV2beta
CTX = Instance.WithAuthorization(ctx, integration.UserTypeOrgOwner)
IAMOwnerCTX = Instance.WithAuthorization(ctx, integration.UserTypeIAMOwner)
UserCTX = Instance.WithAuthorization(ctx, integration.UserTypeNoPermission)
User = createFullUser(CTX)
DeactivatedUser = createDeactivatedUser(CTX)
LockedUser = createLockedUser(CTX)
return m.Run()
}())
}
func createFullUser(ctx context.Context) *user.AddHumanUserResponse {
userResp := Instance.CreateHumanUser(ctx)
Instance.Client.UserV2.VerifyEmail(ctx, &user.VerifyEmailRequest{
UserId: userResp.GetUserId(),
VerificationCode: userResp.GetEmailCode(),
})
Instance.Client.UserV2.VerifyPhone(ctx, &user.VerifyPhoneRequest{
UserId: userResp.GetUserId(),
VerificationCode: userResp.GetPhoneCode(),
})
Instance.SetUserPassword(ctx, userResp.GetUserId(), integration.UserPassword, false)
Instance.RegisterUserPasskey(ctx, userResp.GetUserId())
return userResp
}
func createDeactivatedUser(ctx context.Context) *user.AddHumanUserResponse {
userResp := Instance.CreateHumanUser(ctx)
_, err := Instance.Client.UserV2.DeactivateUser(ctx, &user.DeactivateUserRequest{UserId: userResp.GetUserId()})
logging.OnError(err).Fatal("deactivate human user")
return userResp
}
func createLockedUser(ctx context.Context) *user.AddHumanUserResponse {
userResp := Instance.CreateHumanUser(ctx)
_, err := Instance.Client.UserV2.LockUser(ctx, &user.LockUserRequest{UserId: userResp.GetUserId()})
logging.OnError(err).Fatal("lock human user")
return userResp
}

View File

@ -5,7 +5,6 @@ package session_test
import ( import (
"context" "context"
"fmt" "fmt"
"os"
"testing" "testing"
"time" "time"
@ -14,7 +13,6 @@ import (
"github.com/pquerna/otp/totp" "github.com/pquerna/otp/totp"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/zitadel/logging"
"google.golang.org/grpc/codes" "google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata" "google.golang.org/grpc/metadata"
"google.golang.org/grpc/status" "google.golang.org/grpc/status"
@ -29,62 +27,6 @@ import (
"github.com/zitadel/zitadel/pkg/grpc/user/v2" "github.com/zitadel/zitadel/pkg/grpc/user/v2"
) )
var (
CTX context.Context
IAMOwnerCTX context.Context
Instance *integration.Instance
Client session.SessionServiceClient
User *user.AddHumanUserResponse
DeactivatedUser *user.AddHumanUserResponse
LockedUser *user.AddHumanUserResponse
)
func TestMain(m *testing.M) {
os.Exit(func() int {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
defer cancel()
Instance = integration.NewInstance(ctx)
Client = Instance.Client.SessionV2beta
CTX = Instance.WithAuthorization(ctx, integration.UserTypeOrgOwner)
IAMOwnerCTX = Instance.WithAuthorization(ctx, integration.UserTypeIAMOwner)
User = createFullUser(CTX)
DeactivatedUser = createDeactivatedUser(CTX)
LockedUser = createLockedUser(CTX)
return m.Run()
}())
}
func createFullUser(ctx context.Context) *user.AddHumanUserResponse {
userResp := Instance.CreateHumanUser(ctx)
Instance.Client.UserV2.VerifyEmail(ctx, &user.VerifyEmailRequest{
UserId: userResp.GetUserId(),
VerificationCode: userResp.GetEmailCode(),
})
Instance.Client.UserV2.VerifyPhone(ctx, &user.VerifyPhoneRequest{
UserId: userResp.GetUserId(),
VerificationCode: userResp.GetPhoneCode(),
})
Instance.SetUserPassword(ctx, userResp.GetUserId(), integration.UserPassword, false)
Instance.RegisterUserPasskey(ctx, userResp.GetUserId())
return userResp
}
func createDeactivatedUser(ctx context.Context) *user.AddHumanUserResponse {
userResp := Instance.CreateHumanUser(ctx)
_, err := Instance.Client.UserV2.DeactivateUser(ctx, &user.DeactivateUserRequest{UserId: userResp.GetUserId()})
logging.OnError(err).Fatal("deactivate human user")
return userResp
}
func createLockedUser(ctx context.Context) *user.AddHumanUserResponse {
userResp := Instance.CreateHumanUser(ctx)
_, err := Instance.Client.UserV2.LockUser(ctx, &user.LockUserRequest{UserId: userResp.GetUserId()})
logging.OnError(err).Fatal("lock human user")
return userResp
}
func verifyCurrentSession(t testing.TB, id, token string, sequence uint64, window time.Duration, metadata map[string][]byte, userAgent *session.UserAgent, expirationWindow time.Duration, userID string, factors ...wantFactor) *session.Session { func verifyCurrentSession(t testing.TB, id, token string, sequence uint64, window time.Duration, metadata map[string][]byte, userAgent *session.UserAgent, expirationWindow time.Duration, userID string, factors ...wantFactor) *session.Session {
t.Helper() t.Helper()
require.NotEmpty(t, id) require.NotEmpty(t, id)
@ -96,15 +38,25 @@ func verifyCurrentSession(t testing.TB, id, token string, sequence uint64, windo
}) })
require.NoError(t, err) require.NoError(t, err)
s := resp.GetSession() s := resp.GetSession()
want := &session.Session{
Id: id,
Sequence: sequence,
Metadata: metadata,
UserAgent: userAgent,
}
verifySession(t, s, want, window, expirationWindow, userID, factors...)
return s
}
assert.Equal(t, id, s.GetId()) func verifySession(t assert.TestingT, s *session.Session, want *session.Session, window time.Duration, expirationWindow time.Duration, userID string, factors ...wantFactor) {
assert.Equal(t, want.Id, s.GetId())
assert.WithinRange(t, s.GetCreationDate().AsTime(), time.Now().Add(-window), time.Now().Add(window)) assert.WithinRange(t, s.GetCreationDate().AsTime(), time.Now().Add(-window), time.Now().Add(window))
assert.WithinRange(t, s.GetChangeDate().AsTime(), time.Now().Add(-window), time.Now().Add(window)) assert.WithinRange(t, s.GetChangeDate().AsTime(), time.Now().Add(-window), time.Now().Add(window))
assert.Equal(t, sequence, s.GetSequence()) assert.Equal(t, want.Sequence, s.GetSequence())
assert.Equal(t, metadata, s.GetMetadata()) assert.Equal(t, want.Metadata, s.GetMetadata())
if !proto.Equal(userAgent, s.GetUserAgent()) { if !proto.Equal(want.UserAgent, s.GetUserAgent()) {
t.Errorf("user agent =\n%v\nwant\n%v", s.GetUserAgent(), userAgent) t.Errorf("user agent =\n%v\nwant\n%v", s.GetUserAgent(), want.UserAgent)
} }
if expirationWindow == 0 { if expirationWindow == 0 {
assert.Nil(t, s.GetExpirationDate()) assert.Nil(t, s.GetExpirationDate())
@ -113,7 +65,6 @@ func verifyCurrentSession(t testing.TB, id, token string, sequence uint64, windo
} }
verifyFactors(t, s.GetFactors(), window, userID, factors) verifyFactors(t, s.GetFactors(), window, userID, factors)
return s
} }
type wantFactor int type wantFactor int
@ -129,7 +80,7 @@ const (
wantOTPEmailFactor wantOTPEmailFactor
) )
func verifyFactors(t testing.TB, factors *session.Factors, window time.Duration, userID string, want []wantFactor) { func verifyFactors(t assert.TestingT, factors *session.Factors, window time.Duration, userID string, want []wantFactor) {
for _, w := range want { for _, w := range want {
switch w { switch w {
case wantUserFactor: case wantUserFactor:
@ -194,8 +145,15 @@ func TestServer_CreateSession(t *testing.T) {
}, },
}, },
{ {
name: "user agent", name: "full session",
req: &session.CreateSessionRequest{ req: &session.CreateSessionRequest{
Checks: &session.Checks{
User: &session.CheckUser{
Search: &session.CheckUser_UserId{
UserId: User.GetUserId(),
},
},
},
Metadata: map[string][]byte{"foo": []byte("bar")}, Metadata: map[string][]byte{"foo": []byte("bar")},
UserAgent: &session.UserAgent{ UserAgent: &session.UserAgent{
FingerprintId: gu.Ptr("fingerPrintID"), FingerprintId: gu.Ptr("fingerPrintID"),
@ -205,6 +163,7 @@ func TestServer_CreateSession(t *testing.T) {
"foo": {Values: []string{"foo", "bar"}}, "foo": {Values: []string{"foo", "bar"}},
}, },
}, },
Lifetime: durationpb.New(5 * time.Minute),
}, },
want: &session.CreateSessionResponse{ want: &session.CreateSessionResponse{
Details: &object.Details{ Details: &object.Details{
@ -212,14 +171,6 @@ func TestServer_CreateSession(t *testing.T) {
ResourceOwner: Instance.ID(), ResourceOwner: Instance.ID(),
}, },
}, },
wantUserAgent: &session.UserAgent{
FingerprintId: gu.Ptr("fingerPrintID"),
Ip: gu.Ptr("1.2.3.4"),
Description: gu.Ptr("Description"),
Header: map[string]*session.UserAgent_HeaderValues{
"foo": {Values: []string{"foo", "bar"}},
},
},
}, },
{ {
name: "negative lifetime", name: "negative lifetime",
@ -229,40 +180,6 @@ func TestServer_CreateSession(t *testing.T) {
}, },
wantErr: true, wantErr: true,
}, },
{
name: "lifetime",
req: &session.CreateSessionRequest{
Metadata: map[string][]byte{"foo": []byte("bar")},
Lifetime: durationpb.New(5 * time.Minute),
},
want: &session.CreateSessionResponse{
Details: &object.Details{
ChangeDate: timestamppb.Now(),
ResourceOwner: Instance.ID(),
},
},
wantExpirationWindow: 5 * time.Minute,
},
{
name: "with user",
req: &session.CreateSessionRequest{
Checks: &session.Checks{
User: &session.CheckUser{
Search: &session.CheckUser_UserId{
UserId: User.GetUserId(),
},
},
},
Metadata: map[string][]byte{"foo": []byte("bar")},
},
want: &session.CreateSessionResponse{
Details: &object.Details{
ChangeDate: timestamppb.Now(),
ResourceOwner: Instance.ID(),
},
},
wantFactors: []wantFactor{wantUserFactor},
},
{ {
name: "deactivated user", name: "deactivated user",
req: &session.CreateSessionRequest{ req: &session.CreateSessionRequest{
@ -340,8 +257,6 @@ func TestServer_CreateSession(t *testing.T) {
} }
require.NoError(t, err) require.NoError(t, err)
integration.AssertDetails(t, tt.want, got) integration.AssertDetails(t, tt.want, got)
verifyCurrentSession(t, got.GetSessionId(), got.GetSessionToken(), got.GetDetails().GetSequence(), time.Minute, tt.req.GetMetadata(), tt.wantUserAgent, tt.wantExpirationWindow, User.GetUserId(), tt.wantFactors...)
}) })
} }
} }
@ -946,21 +861,30 @@ func Test_ZITADEL_API_missing_authentication(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
ctx := metadata.AppendToOutgoingContext(context.Background(), "Authorization", fmt.Sprintf("Bearer %s", createResp.GetSessionToken())) ctx := metadata.AppendToOutgoingContext(context.Background(), "Authorization", fmt.Sprintf("Bearer %s", createResp.GetSessionToken()))
sessionResp, err := Client.GetSession(ctx, &session.GetSessionRequest{SessionId: createResp.GetSessionId()}) retryDuration, tick := integration.WaitForAndTickWithMaxDuration(ctx, time.Minute)
require.Error(t, err) require.EventuallyWithT(t, func(tt *assert.CollectT) {
require.Nil(t, sessionResp) sessionResp, err := Client.GetSession(ctx, &session.GetSessionRequest{SessionId: createResp.GetSessionId()})
if !assert.Error(tt, err) {
return
}
assert.Nil(tt, sessionResp)
}, retryDuration, tick)
} }
func Test_ZITADEL_API_success(t *testing.T) { func Test_ZITADEL_API_success(t *testing.T) {
id, token, _, _ := Instance.CreateVerifiedWebAuthNSession(t, CTX, User.GetUserId()) id, token, _, _ := Instance.CreateVerifiedWebAuthNSession(t, CTX, User.GetUserId())
ctx := integration.WithAuthorizationToken(context.Background(), token) ctx := integration.WithAuthorizationToken(context.Background(), token)
sessionResp, err := Client.GetSession(ctx, &session.GetSessionRequest{SessionId: id})
require.NoError(t, err)
webAuthN := sessionResp.GetSession().GetFactors().GetWebAuthN() retryDuration, tick := integration.WaitForAndTickWithMaxDuration(ctx, time.Minute)
require.NotNil(t, id, webAuthN.GetVerifiedAt().AsTime()) require.EventuallyWithT(t, func(tt *assert.CollectT) {
require.True(t, webAuthN.GetUserVerified()) sessionResp, err := Client.GetSession(ctx, &session.GetSessionRequest{SessionId: id})
if !assert.NoError(tt, err) {
return
}
webAuthN := sessionResp.GetSession().GetFactors().GetWebAuthN()
assert.NotNil(tt, id, webAuthN.GetVerifiedAt().AsTime())
assert.True(tt, webAuthN.GetUserVerified())
}, retryDuration, tick)
} }
func Test_ZITADEL_API_session_not_found(t *testing.T) { func Test_ZITADEL_API_session_not_found(t *testing.T) {
@ -968,18 +892,30 @@ func Test_ZITADEL_API_session_not_found(t *testing.T) {
// test session token works // test session token works
ctx := integration.WithAuthorizationToken(context.Background(), token) ctx := integration.WithAuthorizationToken(context.Background(), token)
_, err := Client.GetSession(ctx, &session.GetSessionRequest{SessionId: id})
require.NoError(t, err) retryDuration, tick := integration.WaitForAndTickWithMaxDuration(ctx, time.Minute)
require.EventuallyWithT(t, func(tt *assert.CollectT) {
_, err := Client.GetSession(ctx, &session.GetSessionRequest{SessionId: id})
if !assert.NoError(tt, err) {
return
}
}, retryDuration, tick)
//terminate the session and test it does not work anymore //terminate the session and test it does not work anymore
_, err = Client.DeleteSession(CTX, &session.DeleteSessionRequest{ _, err := Client.DeleteSession(CTX, &session.DeleteSessionRequest{
SessionId: id, SessionId: id,
SessionToken: gu.Ptr(token), SessionToken: gu.Ptr(token),
}) })
require.NoError(t, err) require.NoError(t, err)
ctx = integration.WithAuthorizationToken(context.Background(), token) ctx = integration.WithAuthorizationToken(context.Background(), token)
_, err = Client.GetSession(ctx, &session.GetSessionRequest{SessionId: id}) retryDuration, tick = integration.WaitForAndTickWithMaxDuration(ctx, time.Minute)
require.Error(t, err) require.EventuallyWithT(t, func(tt *assert.CollectT) {
_, err := Client.GetSession(ctx, &session.GetSessionRequest{SessionId: id})
if !assert.Error(tt, err) {
return
}
}, retryDuration, tick)
} }
func Test_ZITADEL_API_session_expired(t *testing.T) { func Test_ZITADEL_API_session_expired(t *testing.T) {
@ -987,8 +923,13 @@ func Test_ZITADEL_API_session_expired(t *testing.T) {
// test session token works // test session token works
ctx := integration.WithAuthorizationToken(context.Background(), token) ctx := integration.WithAuthorizationToken(context.Background(), token)
_, err := Client.GetSession(ctx, &session.GetSessionRequest{SessionId: id}) retryDuration, tick := integration.WaitForAndTickWithMaxDuration(ctx, time.Minute)
require.NoError(t, err) require.EventuallyWithT(t, func(tt *assert.CollectT) {
_, err := Client.GetSession(ctx, &session.GetSessionRequest{SessionId: id})
if !assert.NoError(tt, err) {
return
}
}, retryDuration, tick)
// ensure session expires and does not work anymore // ensure session expires and does not work anymore
time.Sleep(20 * time.Second) time.Sleep(20 * time.Second)

View File

@ -6,6 +6,7 @@ import (
"github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/api/grpc/server" "github.com/zitadel/zitadel/internal/api/grpc/server"
"github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/command"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/internal/query"
session "github.com/zitadel/zitadel/pkg/grpc/session/v2beta" session "github.com/zitadel/zitadel/pkg/grpc/session/v2beta"
) )
@ -16,6 +17,8 @@ type Server struct {
session.UnimplementedSessionServiceServer session.UnimplementedSessionServiceServer
command *command.Commands command *command.Commands
query *query.Queries query *query.Queries
checkPermission domain.PermissionCheck
} }
type Config struct{} type Config struct{}
@ -23,10 +26,12 @@ type Config struct{}
func CreateServer( func CreateServer(
command *command.Commands, command *command.Commands,
query *query.Queries, query *query.Queries,
checkPermission domain.PermissionCheck,
) *Server { ) *Server {
return &Server{ return &Server{
command: command, command: command,
query: query, query: query,
checkPermission: checkPermission,
} }
} }

View File

@ -32,7 +32,7 @@ var (
) )
func (s *Server) GetSession(ctx context.Context, req *session.GetSessionRequest) (*session.GetSessionResponse, error) { func (s *Server) GetSession(ctx context.Context, req *session.GetSessionRequest) (*session.GetSessionResponse, error) {
res, err := s.query.SessionByID(ctx, true, req.GetSessionId(), req.GetSessionToken()) res, err := s.query.SessionByID(ctx, true, req.GetSessionId(), req.GetSessionToken(), s.checkPermission)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -46,7 +46,7 @@ func (s *Server) ListSessions(ctx context.Context, req *session.ListSessionsRequ
if err != nil { if err != nil {
return nil, err return nil, err
} }
sessions, err := s.query.SearchSessions(ctx, queries) sessions, err := s.query.SearchSessions(ctx, queries, s.checkPermission)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@ -10,12 +10,11 @@ import (
"testing" "testing"
"time" "time"
"github.com/zitadel/logging"
"github.com/brianvoe/gofakeit/v6" "github.com/brianvoe/gofakeit/v6"
"github.com/muhlemmer/gu" "github.com/muhlemmer/gu"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/zitadel/logging"
"google.golang.org/grpc/codes" "google.golang.org/grpc/codes"
"google.golang.org/grpc/status" "google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/timestamppb" "google.golang.org/protobuf/types/known/timestamppb"

View File

@ -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)
} }

View File

@ -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)
}

View 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)
}
}

View File

@ -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) }()

View File

@ -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

View File

@ -0,0 +1,22 @@
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,
},
"GET:/scim/v2/" + http.OrgIdInPathVariable + "/Users/{id}": {
Permission: domain.PermissionUserRead,
},
"PUT:/scim/v2/" + http.OrgIdInPathVariable + "/Users/{id}": {
Permission: domain.PermissionUserWrite,
},
"DELETE:/scim/v2/" + http.OrgIdInPathVariable + "/Users/{id}": {
Permission: domain.PermissionUserDelete,
},
}

View File

@ -0,0 +1,6 @@
package config
type Config struct {
EmailVerified bool
PhoneVerified bool
}

View File

@ -0,0 +1,29 @@
//go:build integration
package integration_test
import (
"context"
"os"
"testing"
"time"
"github.com/zitadel/zitadel/internal/integration"
)
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()
}())
}

View 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!"
}

View 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"
}

View 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"
}

View 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"
}

View 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"
}

View 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
}
]
}

View 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
}
],
"active": false
}

View File

@ -0,0 +1,10 @@
{
"schemas": [
"urn:ietf:params:scim:schemas:core:2.0:User"
],
"userName": "acmeUser1",
"name": {
"familyName": "Ross",
"givenName": "Bethany"
}
}

View 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
}
]
}

View 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
}
]
}

View File

@ -0,0 +1,116 @@
{
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
"externalId": "701984-updated",
"userName": "bjensen-replaced-full@example.com",
"name": {
"formatted": "Ms. Barbara J Jensen, III-updated",
"familyName": "Jensen-updated",
"givenName": "Barbara-updated",
"middleName": "Jane-updated",
"honorificPrefix": "Ms.-updated",
"honorificSuffix": "III"
},
"displayName": "Babs Jensen-updated",
"nickName": "Babs-updated",
"profileUrl": "http://login.example.com/bjensen-updated",
"emails": [
{
"value": "bjensen-replaced-full@example.com",
"type": "work-updated",
"primary": true
},
{
"value": "babs-replaced-full@jensen.org",
"type": "home-updated"
}
],
"addresses": [
{
"type": "work-updated",
"streetAddress": "100 Universal City Plaza-updated",
"locality": "Hollywood-updated",
"region": "CA-updated",
"postalCode": "91608-updated",
"country": "USA-updated",
"formatted": "100 Universal City Plaza\nHollywood, CA 91608 USA-updated",
"primary": true
},
{
"type": "home-updated",
"streetAddress": "456 Hollywood Blvd-updated",
"locality": "Hollywood-updated",
"region": "CA-updated",
"postalCode": "91608-updated",
"country": "USA-updated",
"formatted": "456 Hollywood Blvd\nHollywood, CA 91608 USA-updated"
}
],
"phoneNumbers": [
{
"value": "555-555-5555-updated",
"type": "work-updated",
"primary": true
},
{
"value": "555-555-4444-updated",
"type": "mobile-updated"
}
],
"ims": [
{
"value": "someaimhandle-updated",
"type": "aim-updated"
},
{
"value": "twitterhandle-updated",
"type": "X-updated"
}
],
"photos": [
{
"value":
"https://photos.example.com/profilephoto/72930000000Ccne/F-updated",
"type": "photo-updated"
},
{
"value":
"https://photos.example.com/profilephoto/72930000000Ccne/T-updated",
"type": "thumbnail-updated"
}
],
"roles": [
{
"value": "my-role-1-updated",
"display": "Rolle 1-updated",
"type": "main-role-updated",
"primary": true
},
{
"value": "my-role-2-updated",
"display": "Rolle 2-updated",
"type": "secondary-role-updated",
"primary": false
}
],
"entitlements": [
{
"value": "my-entitlement-1-updated",
"display": "Entitlement 1-updated",
"type": "main-entitlement-updated",
"primary": true
},
{
"value": "my-entitlement-2-updated",
"display": "Entitlement 2-updated",
"type": "secondary-entitlement-updated",
"primary": false
}
],
"userType": "Employee-updated",
"title": "Tour Guide-updated",
"preferredLanguage": "en-CH",
"locale": "en-CH",
"timezone": "Europe/Zurich",
"active": false,
"password": "Password1!-updated"
}

View File

@ -0,0 +1,16 @@
{
"schemas": [
"urn:ietf:params:scim:schemas:core:2.0:User"
],
"userName": "acmeUser1-minimal-replaced",
"name": {
"familyName": "Ross-replaced",
"givenName": "Bethany-replaced"
},
"emails": [
{
"value": "user1-minimal-replaced@example.com",
"primary": true
}
]
}

View File

@ -0,0 +1,17 @@
{
"schemas": [
"urn:ietf:params:scim:schemas:core:2.0:User"
],
"externalID": "replaced-external-id",
"userName": "acmeUser1-replaced-with-external-id",
"name": {
"familyName": "Ross",
"givenName": "Bethany"
},
"emails": [
{
"value": "user1-minimal-replaced-with-external-id@example.com",
"primary": true
}
]
}

View File

@ -0,0 +1,405 @@
//go:build integration
package integration_test
import (
"context"
_ "embed"
"net/http"
"path"
"testing"
"time"
"github.com/brianvoe/gofakeit/v6"
"github.com/muhlemmer/gu"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/text/language"
"google.golang.org/grpc/codes"
"github.com/zitadel/zitadel/internal/api/scim/resources"
"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"
)
var (
//go:embed testdata/users_create_test_minimal.json
minimalUserJson []byte
//go:embed testdata/users_create_test_minimal_inactive.json
minimalInactiveUserJson []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
want *resources.ScimUser
wantErr bool
scimErrorType string
errorStatus int
zitadelErrID string
}{
{
name: "minimal user",
body: minimalUserJson,
want: &resources.ScimUser{
UserName: "acmeUser1",
Name: &resources.ScimUserName{
FamilyName: "Ross",
GivenName: "Bethany",
},
Emails: []*resources.ScimEmail{
{
Value: "user1@example.com",
Primary: true,
},
},
},
},
{
name: "minimal inactive user",
body: minimalInactiveUserJson,
want: &resources.ScimUser{
Active: gu.Ptr(false),
},
},
{
name: "full user",
body: fullUserJson,
want: &resources.ScimUser{
ExternalID: "701984",
UserName: "bjensen@example.com",
Name: &resources.ScimUserName{
Formatted: "Babs Jensen", // DisplayName takes precedence in Zitadel
FamilyName: "Jensen",
GivenName: "Barbara",
MiddleName: "Jane",
HonorificPrefix: "Ms.",
HonorificSuffix: "III",
},
DisplayName: "Babs Jensen",
NickName: "Babs",
ProfileUrl: integration.Must(schemas.ParseHTTPURL("http://login.example.com/bjensen")),
Emails: []*resources.ScimEmail{
{
Value: "bjensen@example.com",
Primary: true,
},
},
Addresses: []*resources.ScimAddress{
{
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: []*resources.ScimPhoneNumber{
{
Value: "+415555555555",
Primary: true,
},
},
Ims: []*resources.ScimIms{
{
Value: "someaimhandle",
Type: "aim",
},
{
Value: "twitterhandle",
Type: "X",
},
},
Photos: []*resources.ScimPhoto{
{
Value: *integration.Must(schemas.ParseHTTPURL("https://photos.example.com/profilephoto/72930000000Ccne/F")),
Type: "photo",
},
},
Roles: []*resources.ScimRole{
{
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: []*resources.ScimEntitlement{
{
Value: "my-entitlement-1",
Display: "Entitlement 1",
Type: "main-entitlement",
Primary: true,
},
{
Value: "my-entitlement-2",
Display: "Entitlement 2",
Type: "secondary-entitlement",
Primary: false,
},
},
Title: "Tour Guide",
PreferredLanguage: language.MustParse("en-US"),
Locale: "en-US",
Timezone: "America/Los_Angeles",
Active: gu.Ptr(true),
},
},
{
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 {
statusCode := tt.errorStatus
if statusCode == 0 {
statusCode = http.StatusBadRequest
}
scimErr := scim.RequireScimError(t, statusCode, err)
assert.Equal(t, tt.scimErrorType, scimErr.Error.ScimType)
if tt.zitadelErrID != "" {
assert.Equal(t, tt.zitadelErrID, scimErr.Error.ZitadelDetail.ID)
}
return
}
assert.NotEmpty(t, createdUser.ID)
defer func() {
_, err = Instance.Client.UserV2.DeleteUser(CTX, &user.DeleteUserRequest{UserId: createdUser.ID})
assert.NoError(t, err)
}()
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)
if tt.want != nil {
if !integration.PartiallyDeepEqual(tt.want, createdUser) {
t.Errorf("CreateUser() got = %v, want %v", createdUser, tt.want)
}
retryDuration, tick := integration.WaitForAndTickWithMaxDuration(CTX, time.Minute)
require.EventuallyWithT(t, func(ttt *assert.CollectT) {
// ensure the user is really stored and not just returned to the caller
fetchedUser, err := Instance.Client.SCIM.Users.Get(CTX, Instance.DefaultOrg.Id, createdUser.ID)
require.NoError(ttt, err)
if !integration.PartiallyDeepEqual(tt.want, fetchedUser) {
ttt.Errorf("GetUser() got = %v, want %v", fetchedUser, tt.want)
}
}, retryDuration, tick)
}
})
}
}
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)
scimErr := scim.RequireScimError(t, http.StatusConflict, err)
assert.Equal(t, "User already exists", scimErr.Error.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)
defer func() {
_, err = Instance.Client.UserV2.DeleteUser(CTX, &user.DeleteUserRequest{UserId: createdUser.ID})
require.NoError(t, err)
}()
retryDuration, tick := integration.WaitForAndTickWithMaxDuration(CTX, time.Minute)
require.EventuallyWithT(t, func(tt *assert.CollectT) {
md, err := Instance.Client.Mgmt.ListUserMetadata(CTX, &management.ListUserMetadataRequest{
Id: createdUser.ID,
})
require.NoError(tt, err)
mdMap := make(map[string]string)
for i := range md.Result {
mdMap[md.Result[i].Key] = string(md.Result[i].Value)
}
integration.AssertMapContains(tt, mdMap, "urn:zitadel:scim:name.honorificPrefix", "Ms.")
integration.AssertMapContains(tt, mdMap, "urn:zitadel:scim:timezone", "America/Los_Angeles")
integration.AssertMapContains(tt, 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(tt, 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(tt, 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(tt, mdMap, "urn:zitadel:scim:externalId", "701984")
integration.AssertMapContains(tt, mdMap, "urn:zitadel:scim:name.middleName", "Jane")
integration.AssertMapContains(tt, mdMap, "urn:zitadel:scim:name.honorificSuffix", "III")
integration.AssertMapContains(tt, mdMap, "urn:zitadel:scim:profileURL", "http://login.example.com/bjensen")
integration.AssertMapContains(tt, mdMap, "urn:zitadel:scim:title", "Tour Guide")
integration.AssertMapContains(tt, mdMap, "urn:zitadel:scim:locale", "en-US")
integration.AssertMapContains(tt, mdMap, "urn:zitadel:scim:ims", `[{"value":"someaimhandle","type":"aim"},{"value":"twitterhandle","type":"X"}]`)
integration.AssertMapContains(tt, 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"}]`)
}, retryDuration, tick)
}
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)
defer func() {
_, err = Instance.Client.UserV2.DeleteUser(CTX, &user.DeleteUserRequest{UserId: createdUser.ID})
require.NoError(t, err)
_, err = Instance.Client.Mgmt.RemoveUserMetadata(CTX, &management.RemoveUserMetadataRequest{
Id: Instance.Users.Get(integration.UserTypeOrgOwner).ID,
Key: "urn:zitadel:scim:provisioning_domain",
})
require.NoError(t, err)
}()
retryDuration, tick := integration.WaitForAndTickWithMaxDuration(CTX, time.Minute)
require.EventuallyWithT(t, func(tt *assert.CollectT) {
// unscoped externalID should not exist
_, err = Instance.Client.Mgmt.GetUserMetadata(CTX, &management.GetUserMetadataRequest{
Id: createdUser.ID,
Key: "urn:zitadel:scim:externalId",
})
integration.AssertGrpcStatus(tt, 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(tt, err)
assert.Equal(tt, "701984", string(md.Metadata.Value))
}, retryDuration, tick)
}
func TestCreateUser_anotherOrg(t *testing.T) {
org := Instance.CreateOrganization(Instance.WithAuthorization(CTX, integration.UserTypeIAMOwner), gofakeit.Name(), gofakeit.Email())
_, err := Instance.Client.SCIM.Users.Create(CTX, org.OrganizationId, fullUserJson)
scim.RequireScimError(t, http.StatusNotFound, err)
}

View File

@ -0,0 +1,90 @@
//go:build integration
package integration_test
import (
"context"
"net/http"
"testing"
"time"
"github.com/brianvoe/gofakeit/v6"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/codes"
"github.com/zitadel/zitadel/internal/integration"
"github.com/zitadel/zitadel/internal/integration/scim"
"github.com/zitadel/zitadel/pkg/grpc/user/v2"
)
func TestDeleteUser_errors(t *testing.T) {
tests := []struct {
name string
ctx context.Context
errorStatus int
}{
{
name: "not authenticated",
ctx: context.Background(),
errorStatus: http.StatusUnauthorized,
},
{
name: "no permissions",
ctx: Instance.WithAuthorization(CTX, integration.UserTypeNoPermission),
errorStatus: http.StatusNotFound,
},
{
name: "unknown user id",
errorStatus: http.StatusNotFound,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := tt.ctx
if ctx == nil {
ctx = CTX
}
err := Instance.Client.SCIM.Users.Delete(ctx, Instance.DefaultOrg.Id, "1")
statusCode := tt.errorStatus
if statusCode == 0 {
statusCode = http.StatusBadRequest
}
scim.RequireScimError(t, statusCode, err)
})
}
}
func TestDeleteUser_ensureReallyDeleted(t *testing.T) {
// create user and dependencies
createUserResp := Instance.CreateHumanUser(CTX)
proj, err := Instance.CreateProject(CTX)
require.NoError(t, err)
Instance.CreateProjectUserGrant(t, CTX, proj.Id, createUserResp.UserId)
// delete user via scim
err = Instance.Client.SCIM.Users.Delete(CTX, Instance.DefaultOrg.Id, createUserResp.UserId)
assert.NoError(t, err)
// ensure it is really deleted => try to delete again => should 404
err = Instance.Client.SCIM.Users.Delete(CTX, Instance.DefaultOrg.Id, createUserResp.UserId)
scim.RequireScimError(t, http.StatusNotFound, err)
retryDuration, tick := integration.WaitForAndTickWithMaxDuration(CTX, time.Minute)
require.EventuallyWithT(t, func(tt *assert.CollectT) {
// try to get user via api => should 404
_, err = Instance.Client.UserV2.GetUserByID(CTX, &user.GetUserByIDRequest{UserId: createUserResp.UserId})
integration.AssertGrpcStatus(tt, codes.NotFound, err)
}, retryDuration, tick)
}
func TestDeleteUser_anotherOrg(t *testing.T) {
createUserResp := Instance.CreateHumanUser(CTX)
org := Instance.CreateOrganization(Instance.WithAuthorization(CTX, integration.UserTypeIAMOwner), gofakeit.Name(), gofakeit.Email())
err := Instance.Client.SCIM.Users.Delete(CTX, org.OrganizationId, createUserResp.UserId)
scim.RequireScimError(t, http.StatusNotFound, err)
}

View File

@ -0,0 +1,276 @@
//go:build integration
package integration_test
import (
"context"
"net/http"
"path"
"testing"
"time"
"github.com/brianvoe/gofakeit/v6"
"github.com/muhlemmer/gu"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/text/language"
"github.com/zitadel/zitadel/internal/api/scim/resources"
"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"
)
func TestGetUser(t *testing.T) {
tests := []struct {
name string
buildUserID func() string
cleanup func(userID string)
ctx context.Context
want *resources.ScimUser
wantErr bool
errorStatus int
}{
{
name: "not authenticated",
ctx: context.Background(),
errorStatus: http.StatusUnauthorized,
wantErr: true,
},
{
name: "no permissions",
ctx: Instance.WithAuthorization(CTX, integration.UserTypeNoPermission),
errorStatus: http.StatusNotFound,
wantErr: true,
},
{
name: "unknown user id",
buildUserID: func() string {
return "unknown"
},
errorStatus: http.StatusNotFound,
wantErr: true,
},
{
name: "created via grpc",
want: &resources.ScimUser{
Name: &resources.ScimUserName{
FamilyName: "Mouse",
GivenName: "Mickey",
},
PreferredLanguage: language.MustParse("nl"),
PhoneNumbers: []*resources.ScimPhoneNumber{
{
Value: "+41791234567",
Primary: true,
},
},
},
},
{
name: "created via scim",
buildUserID: func() string {
createdUser, err := Instance.Client.SCIM.Users.Create(CTX, Instance.DefaultOrg.Id, fullUserJson)
require.NoError(t, err)
return createdUser.ID
},
cleanup: func(userID string) {
_, err := Instance.Client.UserV2.DeleteUser(CTX, &user.DeleteUserRequest{UserId: userID})
require.NoError(t, err)
},
want: &resources.ScimUser{
ExternalID: "701984",
UserName: "bjensen@example.com",
Name: &resources.ScimUserName{
Formatted: "Babs Jensen", // DisplayName takes precedence
FamilyName: "Jensen",
GivenName: "Barbara",
MiddleName: "Jane",
HonorificPrefix: "Ms.",
HonorificSuffix: "III",
},
DisplayName: "Babs Jensen",
NickName: "Babs",
ProfileUrl: integration.Must(schemas.ParseHTTPURL("http://login.example.com/bjensen")),
Title: "Tour Guide",
PreferredLanguage: language.Make("en-US"),
Locale: "en-US",
Timezone: "America/Los_Angeles",
Active: gu.Ptr(true),
Emails: []*resources.ScimEmail{
{
Value: "bjensen@example.com",
Primary: true,
},
},
PhoneNumbers: []*resources.ScimPhoneNumber{
{
Value: "+415555555555",
Primary: true,
},
},
Ims: []*resources.ScimIms{
{
Value: "someaimhandle",
Type: "aim",
},
{
Value: "twitterhandle",
Type: "X",
},
},
Addresses: []*resources.ScimAddress{
{
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",
},
},
Photos: []*resources.ScimPhoto{
{
Value: *integration.Must(schemas.ParseHTTPURL("https://photos.example.com/profilephoto/72930000000Ccne/F")),
Type: "photo",
},
{
Value: *integration.Must(schemas.ParseHTTPURL("https://photos.example.com/profilephoto/72930000000Ccne/T")),
Type: "thumbnail",
},
},
Roles: []*resources.ScimRole{
{
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: []*resources.ScimEntitlement{
{
Value: "my-entitlement-1",
Display: "Entitlement 1",
Type: "main-entitlement",
Primary: true,
},
{
Value: "my-entitlement-2",
Display: "Entitlement 2",
Type: "secondary-entitlement",
Primary: false,
},
},
},
},
{
name: "scoped externalID",
buildUserID: func() string {
// create user without provisioning domain
createdUser, err := Instance.Client.SCIM.Users.Create(CTX, Instance.DefaultOrg.Id, fullUserJson)
require.NoError(t, err)
// set provisioning domain of service user
_, 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)
// set externalID for provisioning domain
_, err = Instance.Client.Mgmt.SetUserMetadata(CTX, &management.SetUserMetadataRequest{
Id: createdUser.ID,
Key: "urn:zitadel:scim:fooBar:externalId",
Value: []byte("100-scopedExternalId"),
})
require.NoError(t, err)
return createdUser.ID
},
cleanup: func(userID string) {
_, err := Instance.Client.UserV2.DeleteUser(CTX, &user.DeleteUserRequest{UserId: userID})
require.NoError(t, err)
_, err = Instance.Client.Mgmt.RemoveUserMetadata(CTX, &management.RemoveUserMetadataRequest{
Id: Instance.Users.Get(integration.UserTypeOrgOwner).ID,
Key: "urn:zitadel:scim:provisioning_domain",
})
require.NoError(t, err)
},
want: &resources.ScimUser{
ExternalID: "100-scopedExternalId",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := tt.ctx
if ctx == nil {
ctx = CTX
}
var userID string
if tt.buildUserID != nil {
userID = tt.buildUserID()
} else {
createUserResp := Instance.CreateHumanUser(CTX)
userID = createUserResp.UserId
}
retryDuration, tick := integration.WaitForAndTickWithMaxDuration(CTX, time.Minute)
var fetchedUser *resources.ScimUser
var err error
require.EventuallyWithT(t, func(ttt *assert.CollectT) {
fetchedUser, err = Instance.Client.SCIM.Users.Get(ctx, Instance.DefaultOrg.Id, userID)
if tt.wantErr {
statusCode := tt.errorStatus
if statusCode == 0 {
statusCode = http.StatusBadRequest
}
scim.RequireScimError(ttt, statusCode, err)
return
}
assert.Equal(ttt, userID, fetchedUser.ID)
assert.EqualValues(ttt, []schemas.ScimSchemaType{"urn:ietf:params:scim:schemas:core:2.0:User"}, fetchedUser.Schemas)
assert.Equal(ttt, schemas.ScimResourceTypeSingular("User"), fetchedUser.Resource.Meta.ResourceType)
assert.Equal(ttt, "http://"+Instance.Host()+path.Join(schemas.HandlerPrefix, Instance.DefaultOrg.Id, "Users", fetchedUser.ID), fetchedUser.Resource.Meta.Location)
assert.Nil(ttt, fetchedUser.Password)
if !integration.PartiallyDeepEqual(tt.want, fetchedUser) {
ttt.Errorf("GetUser() got = %#v, want %#v", fetchedUser, tt.want)
}
}, retryDuration, tick)
if tt.cleanup != nil {
tt.cleanup(fetchedUser.ID)
}
})
}
}
func TestGetUser_anotherOrg(t *testing.T) {
createUserResp := Instance.CreateHumanUser(CTX)
org := Instance.CreateOrganization(Instance.WithAuthorization(CTX, integration.UserTypeIAMOwner), gofakeit.Name(), gofakeit.Email())
_, err := Instance.Client.SCIM.Users.Get(CTX, org.OrganizationId, createUserResp.UserId)
scim.RequireScimError(t, http.StatusNotFound, err)
}

View File

@ -0,0 +1,331 @@
//go:build integration
package integration_test
import (
"context"
_ "embed"
"net/http"
"path"
"testing"
"time"
"github.com/muhlemmer/gu"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/text/language"
"github.com/zitadel/zitadel/internal/api/scim/resources"
"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"
)
var (
//go:embed testdata/users_replace_test_minimal_with_external_id.json
minimalUserWithExternalIDJson []byte
//go:embed testdata/users_replace_test_minimal.json
minimalUserReplaceJson []byte
//go:embed testdata/users_replace_test_full.json
fullUserReplaceJson []byte
)
func TestReplaceUser(t *testing.T) {
tests := []struct {
name string
body []byte
ctx context.Context
want *resources.ScimUser
wantErr bool
scimErrorType string
errorStatus int
zitadelErrID string
}{
{
name: "minimal user",
body: minimalUserReplaceJson,
want: &resources.ScimUser{
UserName: "acmeUser1-minimal-replaced",
Name: &resources.ScimUserName{
FamilyName: "Ross-replaced",
GivenName: "Bethany-replaced",
},
Emails: []*resources.ScimEmail{
{
Value: "user1-minimal-replaced@example.com",
Primary: true,
},
},
},
},
{
name: "full user",
body: fullUserReplaceJson,
want: &resources.ScimUser{
ExternalID: "701984-updated",
UserName: "bjensen-replaced-full@example.com",
Name: &resources.ScimUserName{
Formatted: "Babs Jensen-updated", // display name takes precedence
FamilyName: "Jensen-updated",
GivenName: "Barbara-updated",
MiddleName: "Jane-updated",
HonorificPrefix: "Ms.-updated",
HonorificSuffix: "III",
},
DisplayName: "Babs Jensen-updated",
NickName: "Babs-updated",
ProfileUrl: integration.Must(schemas.ParseHTTPURL("http://login.example.com/bjensen-updated")),
Emails: []*resources.ScimEmail{
{
Value: "bjensen-replaced-full@example.com",
Primary: true,
},
},
Addresses: []*resources.ScimAddress{
{
Type: "work-updated",
StreetAddress: "100 Universal City Plaza-updated",
Locality: "Hollywood-updated",
Region: "CA-updated",
PostalCode: "91608-updated",
Country: "USA-updated",
Formatted: "100 Universal City Plaza\nHollywood, CA 91608 USA-updated",
Primary: true,
},
{
Type: "home-updated",
StreetAddress: "456 Hollywood Blvd-updated",
Locality: "Hollywood-updated",
Region: "CA-updated",
PostalCode: "91608-updated",
Country: "USA-updated",
Formatted: "456 Hollywood Blvd\nHollywood, CA 91608 USA-updated",
},
},
PhoneNumbers: []*resources.ScimPhoneNumber{
{
Value: "+4155555555558732833",
Primary: true,
},
},
Ims: []*resources.ScimIms{
{
Value: "someaimhandle-updated",
Type: "aim-updated",
},
{
Value: "twitterhandle-updated",
Type: "X-updated",
},
},
Photos: []*resources.ScimPhoto{
{
Value: *integration.Must(schemas.ParseHTTPURL("https://photos.example.com/profilephoto/72930000000Ccne/F-updated")),
Type: "photo-updated",
},
{
Value: *integration.Must(schemas.ParseHTTPURL("https://photos.example.com/profilephoto/72930000000Ccne/T-updated")),
Type: "thumbnail-updated",
},
},
Roles: []*resources.ScimRole{
{
Value: "my-role-1-updated",
Display: "Rolle 1-updated",
Type: "main-role-updated",
Primary: true,
},
{
Value: "my-role-2-updated",
Display: "Rolle 2-updated",
Type: "secondary-role-updated",
Primary: false,
},
},
Entitlements: []*resources.ScimEntitlement{
{
Value: "my-entitlement-1-updated",
Display: "Entitlement 1-updated",
Type: "main-entitlement-updated",
Primary: true,
},
{
Value: "my-entitlement-2-updated",
Display: "Entitlement 2-updated",
Type: "secondary-entitlement-updated",
Primary: false,
},
},
Title: "Tour Guide-updated",
PreferredLanguage: language.MustParse("en-CH"),
Locale: "en-CH",
Timezone: "Europe/Zurich",
Active: gu.Ptr(false),
},
},
{
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) {
createdUser, err := Instance.Client.SCIM.Users.Create(CTX, Instance.DefaultOrg.Id, fullUserJson)
require.NoError(t, err)
defer func() {
_, err = Instance.Client.UserV2.DeleteUser(CTX, &user.DeleteUserRequest{UserId: createdUser.ID})
assert.NoError(t, err)
}()
ctx := tt.ctx
if ctx == nil {
ctx = CTX
}
replacedUser, err := Instance.Client.SCIM.Users.Replace(ctx, Instance.DefaultOrg.Id, createdUser.ID, tt.body)
if (err != nil) != tt.wantErr {
t.Errorf("ReplaceUser() error = %v, wantErr %v", err, tt.wantErr)
}
if err != nil {
statusCode := tt.errorStatus
if statusCode == 0 {
statusCode = http.StatusBadRequest
}
scimErr := scim.RequireScimError(t, statusCode, err)
assert.Equal(t, tt.scimErrorType, scimErr.Error.ScimType)
if tt.zitadelErrID != "" {
assert.Equal(t, tt.zitadelErrID, scimErr.Error.ZitadelDetail.ID)
}
return
}
assert.NotEmpty(t, replacedUser.ID)
assert.EqualValues(t, []schemas.ScimSchemaType{"urn:ietf:params:scim:schemas:core:2.0:User"}, replacedUser.Resource.Schemas)
assert.Equal(t, schemas.ScimResourceTypeSingular("User"), replacedUser.Resource.Meta.ResourceType)
assert.Equal(t, "http://"+Instance.Host()+path.Join(schemas.HandlerPrefix, Instance.DefaultOrg.Id, "Users", createdUser.ID), replacedUser.Resource.Meta.Location)
assert.Nil(t, createdUser.Password)
if !integration.PartiallyDeepEqual(tt.want, replacedUser) {
t.Errorf("ReplaceUser() got = %#v, want %#v", replacedUser, tt.want)
}
retryDuration, tick := integration.WaitForAndTickWithMaxDuration(CTX, time.Minute)
require.EventuallyWithT(t, func(ttt *assert.CollectT) {
// ensure the user is really stored and not just returned to the caller
fetchedUser, err := Instance.Client.SCIM.Users.Get(CTX, Instance.DefaultOrg.Id, replacedUser.ID)
require.NoError(ttt, err)
if !integration.PartiallyDeepEqual(tt.want, fetchedUser) {
ttt.Errorf("GetUser() got = %#v, want %#v", fetchedUser, tt.want)
}
}, retryDuration, tick)
})
}
}
func TestReplaceUser_removeOldMetadata(t *testing.T) {
// ensure old metadata is removed correctly
createdUser, err := Instance.Client.SCIM.Users.Create(CTX, Instance.DefaultOrg.Id, fullUserJson)
require.NoError(t, err)
_, err = Instance.Client.SCIM.Users.Replace(CTX, Instance.DefaultOrg.Id, createdUser.ID, minimalUserJson)
require.NoError(t, err)
retryDuration, tick := integration.WaitForAndTickWithMaxDuration(CTX, time.Minute)
require.EventuallyWithT(t, func(tt *assert.CollectT) {
md, err := Instance.Client.Mgmt.ListUserMetadata(CTX, &management.ListUserMetadataRequest{
Id: createdUser.ID,
})
require.NoError(tt, err)
require.Equal(tt, 0, len(md.Result))
}, retryDuration, tick)
_, err = Instance.Client.UserV2.DeleteUser(CTX, &user.DeleteUserRequest{UserId: createdUser.ID})
require.NoError(t, err)
}
func TestReplaceUser_scopedExternalID(t *testing.T) {
// create user without provisioning domain set
createdUser, err := Instance.Client.SCIM.Users.Create(CTX, Instance.DefaultOrg.Id, fullUserJson)
require.NoError(t, err)
// set provisioning domain of service user
_, err = Instance.Client.Mgmt.SetUserMetadata(CTX, &management.SetUserMetadataRequest{
Id: Instance.Users.Get(integration.UserTypeOrgOwner).ID,
Key: "urn:zitadel:scim:provisioning_domain",
Value: []byte("fooBazz"),
})
require.NoError(t, err)
// replace the user with provisioning domain set
_, err = Instance.Client.SCIM.Users.Replace(CTX, Instance.DefaultOrg.Id, createdUser.ID, minimalUserWithExternalIDJson)
require.NoError(t, err)
retryDuration, tick := integration.WaitForAndTickWithMaxDuration(CTX, time.Minute)
require.EventuallyWithT(t, func(tt *assert.CollectT) {
md, err := Instance.Client.Mgmt.ListUserMetadata(CTX, &management.ListUserMetadataRequest{
Id: createdUser.ID,
})
require.NoError(tt, err)
mdMap := make(map[string]string)
for i := range md.Result {
mdMap[md.Result[i].Key] = string(md.Result[i].Value)
}
// both external IDs should be present on the user
integration.AssertMapContains(tt, mdMap, "urn:zitadel:scim:externalId", "701984")
integration.AssertMapContains(tt, mdMap, "urn:zitadel:scim:fooBazz:externalId", "replaced-external-id")
}, retryDuration, tick)
_, err = Instance.Client.UserV2.DeleteUser(CTX, &user.DeleteUserRequest{UserId: createdUser.ID})
require.NoError(t, err)
_, err = Instance.Client.Mgmt.RemoveUserMetadata(CTX, &management.RemoveUserMetadataRequest{
Id: Instance.Users.Get(integration.UserTypeOrgOwner).ID,
Key: "urn:zitadel:scim:provisioning_domain",
})
require.NoError(t, err)
}

View 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
}

View 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)
}

View 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")
}

View 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)
}
})
}
}

View 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
}

View File

@ -0,0 +1,64 @@
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)
Replace(ctx context.Context, id string, resource T) (T, error)
Delete(ctx context.Context, id string) error
Get(ctx context.Context, id string) (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)
}

View File

@ -0,0 +1,91 @@
package resources
import (
"encoding/json"
"net/http"
"slices"
"github.com/gorilla/mux"
"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]) Replace(r *http.Request) (T, error) {
entity, err := adapter.readEntityFromBody(r)
if err != nil {
return entity, err
}
id := mux.Vars(r)["id"]
return adapter.handler.Replace(r.Context(), id, entity)
}
func (adapter *ResourceHandlerAdapter[T]) Delete(r *http.Request) error {
id := mux.Vars(r)["id"]
return adapter.handler.Delete(r.Context(), id)
}
func (adapter *ResourceHandlerAdapter[T]) Get(r *http.Request) (T, error) {
id := mux.Vars(r)["id"]
return adapter.handler.Get(r.Context(), id)
}
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
}

View File

@ -0,0 +1,212 @@
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"
scim_schemas "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 *scim_schemas.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 *scim_schemas.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 scim_schemas.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() scim_schemas.ScimResourceTypeSingular {
return scim_schemas.UserResourceType
}
func (h *UsersHandler) ResourceNamePlural() scim_schemas.ScimResourceTypePlural {
return scim_schemas.UsersResourceType
}
func (u *ScimUser) GetResource() *Resource {
return u.Resource
}
func (h *UsersHandler) NewResource() *ScimUser {
return new(ScimUser)
}
func (h *UsersHandler) SchemaType() scim_schemas.ScimSchemaType {
return scim_schemas.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
}
h.mapAddCommandToScimUser(ctx, user, addHuman)
return user, nil
}
func (h *UsersHandler) Replace(ctx context.Context, id string, user *ScimUser) (*ScimUser, error) {
user.ID = id
changeHuman, err := h.mapToChangeHuman(ctx, user)
if err != nil {
return nil, err
}
err = h.command.ChangeUserHuman(ctx, changeHuman, h.userCodeAlg)
if err != nil {
return nil, err
}
h.mapChangeCommandToScimUser(ctx, user, changeHuman)
return user, nil
}
func (h *UsersHandler) Delete(ctx context.Context, id string) error {
memberships, grants, err := h.queryUserDependencies(ctx, id)
if err != nil {
return err
}
_, err = h.command.RemoveUserV2(ctx, id, memberships, grants...)
return err
}
func (h *UsersHandler) Get(ctx context.Context, id string) (*ScimUser, error) {
user, err := h.query.GetUserByID(ctx, false, id)
if err != nil {
return nil, err
}
metadata, err := h.queryMetadataForUser(ctx, id)
if err != nil {
return nil, err
}
return h.mapToScimUser(ctx, user, metadata), nil
}
func (h *UsersHandler) queryUserDependencies(ctx context.Context, userID string) ([]*command.CascadingMembership, []string, error) {
userGrantUserQuery, err := query.NewUserGrantUserIDSearchQuery(userID)
if err != nil {
return nil, nil, err
}
grants, err := h.query.UserGrants(ctx, &query.UserGrantsQueries{
Queries: []query.SearchQuery{userGrantUserQuery},
}, true)
if err != nil {
return nil, nil, err
}
membershipsUserQuery, err := query.NewMembershipUserIDQuery(userID)
if err != nil {
return nil, nil, err
}
memberships, err := h.query.Memberships(ctx, &query.MembershipSearchQuery{
Queries: []query.SearchQuery{membershipsUserQuery},
}, false)
if err != nil {
return nil, nil, err
}
return cascadingMemberships(memberships.Memberships), userGrantsToIDs(grants.UserGrants), nil
}

View File

@ -0,0 +1,366 @@
package resources
import (
"context"
"strconv"
"time"
"github.com/muhlemmer/gu"
"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/command"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/query"
)
func (h *UsersHandler) mapToAddHuman(ctx context.Context, scimUser *ScimUser) (*command.AddHuman, error) {
human := &command.AddHuman{
Username: scimUser.UserName,
NickName: scimUser.NickName,
DisplayName: scimUser.DisplayName,
}
if scimUser.Active != nil && !*scimUser.Active {
human.SetInactive = true
}
if email := h.mapPrimaryEmail(scimUser); email != nil {
human.Email = *email
}
if phone := h.mapPrimaryPhone(scimUser); phone != nil {
human.Phone = *phone
}
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
} else {
// update user to match the actual stored value
scimUser.Name.Formatted = human.DisplayName
}
}
if err := domain.LanguageIsDefined(scimUser.PreferredLanguage); err != nil {
human.PreferredLanguage = language.English
scimUser.PreferredLanguage = language.English
}
return human, nil
}
func (h *UsersHandler) mapToChangeHuman(ctx context.Context, scimUser *ScimUser) (*command.ChangeHuman, error) {
human := &command.ChangeHuman{
ID: scimUser.ID,
Username: &scimUser.UserName,
Profile: &command.Profile{
NickName: &scimUser.NickName,
DisplayName: &scimUser.DisplayName,
},
Email: h.mapPrimaryEmail(scimUser),
Phone: h.mapPrimaryPhone(scimUser),
}
if scimUser.Active != nil {
if *scimUser.Active {
human.State = gu.Ptr(domain.UserStateActive)
} else {
human.State = gu.Ptr(domain.UserStateInactive)
}
}
md, mdRemovedKeys, err := h.mapMetadataToDomain(ctx, scimUser)
if err != nil {
return nil, err
}
human.Metadata = md
human.MetadataKeysToRemove = mdRemovedKeys
if scimUser.Password != nil {
human.Password = &command.Password{
Password: scimUser.Password.String(),
}
scimUser.Password = nil
}
if scimUser.Name != nil {
human.Profile.FirstName = &scimUser.Name.GivenName
human.Profile.LastName = &scimUser.Name.FamilyName
// the direct mapping displayName => displayName has priority
// over the formatted name assignment
if *human.Profile.DisplayName == "" {
human.Profile.DisplayName = &scimUser.Name.Formatted
} else {
// update user to match the actual stored value
scimUser.Name.Formatted = *human.Profile.DisplayName
}
}
if err := domain.LanguageIsDefined(scimUser.PreferredLanguage); err != nil {
human.Profile.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 nil
}
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 nil
}
func (h *UsersHandler) mapAddCommandToScimUser(ctx context.Context, user *ScimUser, addHuman *command.AddHuman) {
user.ID = addHuman.Details.ID
user.Resource = buildResource(ctx, h, addHuman.Details)
user.Password = nil
// ZITADEL supports only one (primary) phone number or email.
// Therefore, only the primary one should be returned.
// Note that the phone number might also be reformatted.
if addHuman.Phone.Number != "" {
user.PhoneNumbers = []*ScimPhoneNumber{
{
Value: string(addHuman.Phone.Number),
Primary: true,
},
}
}
if addHuman.Email.Address != "" {
user.Emails = []*ScimEmail{
{
Value: string(addHuman.Email.Address),
Primary: true,
},
}
}
}
func (h *UsersHandler) mapChangeCommandToScimUser(ctx context.Context, user *ScimUser, changeHuman *command.ChangeHuman) {
user.ID = changeHuman.Details.ID
user.Resource = buildResource(ctx, h, changeHuman.Details)
user.Password = nil
// ZITADEL supports only one (primary) phone number or email.
// Therefore, only the primary one should be returned.
// Note that the phone number might also be reformatted.
if changeHuman.Phone != nil {
user.PhoneNumbers = []*ScimPhoneNumber{
{
Value: string(changeHuman.Phone.Number),
Primary: true,
},
}
}
if changeHuman.Email != nil {
user.Emails = []*ScimEmail{
{
Value: string(changeHuman.Email.Address),
Primary: true,
},
}
}
}
func (h *UsersHandler) mapToScimUser(ctx context.Context, user *query.User, md map[metadata.ScopedKey][]byte) *ScimUser {
scimUser := &ScimUser{
Resource: h.buildResourceForQuery(ctx, user),
ID: user.ID,
ExternalID: extractScalarMetadata(ctx, md, metadata.KeyExternalId),
UserName: user.Username,
ProfileUrl: extractHttpURLMetadata(ctx, md, metadata.KeyProfileUrl),
Title: extractScalarMetadata(ctx, md, metadata.KeyTitle),
Locale: extractScalarMetadata(ctx, md, metadata.KeyLocale),
Timezone: extractScalarMetadata(ctx, md, metadata.KeyTimezone),
Active: gu.Ptr(user.State.IsEnabled()),
Ims: make([]*ScimIms, 0),
Addresses: make([]*ScimAddress, 0),
Photos: make([]*ScimPhoto, 0),
Entitlements: make([]*ScimEntitlement, 0),
Roles: make([]*ScimRole, 0),
}
if scimUser.Locale != "" {
_, err := language.Parse(scimUser.Locale)
if err != nil {
logging.OnError(err).Warn("Failed to load locale of scim user")
scimUser.Locale = ""
}
}
if scimUser.Timezone != "" {
_, err := time.LoadLocation(scimUser.Timezone)
if err != nil {
logging.OnError(err).Warn("Failed to load timezone of scim user")
scimUser.Timezone = ""
}
}
if err := extractJsonMetadata(ctx, md, metadata.KeyIms, &scimUser.Ims); err != nil {
logging.OnError(err).Warn("Could not deserialize scim ims metadata")
}
if err := extractJsonMetadata(ctx, md, metadata.KeyAddresses, &scimUser.Addresses); err != nil {
logging.OnError(err).Warn("Could not deserialize scim addresses metadata")
}
if err := extractJsonMetadata(ctx, md, metadata.KeyPhotos, &scimUser.Photos); err != nil {
logging.OnError(err).Warn("Could not deserialize scim photos metadata")
}
if err := extractJsonMetadata(ctx, md, metadata.KeyEntitlements, &scimUser.Entitlements); err != nil {
logging.OnError(err).Warn("Could not deserialize scim entitlements metadata")
}
if err := extractJsonMetadata(ctx, md, metadata.KeyRoles, &scimUser.Roles); err != nil {
logging.OnError(err).Warn("Could not deserialize scim roles metadata")
}
if user.Human != nil {
mapHumanToScimUser(ctx, user.Human, scimUser, md)
}
return scimUser
}
func mapHumanToScimUser(ctx context.Context, human *query.Human, user *ScimUser, md map[metadata.ScopedKey][]byte) {
user.DisplayName = human.DisplayName
user.NickName = human.NickName
user.PreferredLanguage = human.PreferredLanguage
user.Name = &ScimUserName{
Formatted: human.DisplayName,
FamilyName: human.LastName,
GivenName: human.FirstName,
MiddleName: extractScalarMetadata(ctx, md, metadata.KeyMiddleName),
HonorificPrefix: extractScalarMetadata(ctx, md, metadata.KeyHonorificPrefix),
HonorificSuffix: extractScalarMetadata(ctx, md, metadata.KeyHonorificSuffix),
}
if string(human.Email) != "" {
user.Emails = []*ScimEmail{
{
Value: string(human.Email),
Primary: true,
},
}
}
if string(human.Phone) != "" {
user.PhoneNumbers = []*ScimPhoneNumber{
{
Value: string(human.Phone),
Primary: true,
},
}
}
}
func (h *UsersHandler) buildResourceForQuery(ctx context.Context, user *query.User) *Resource {
return &Resource{
Schemas: []schemas.ScimSchemaType{schemas.IdUser},
Meta: &ResourceMeta{
ResourceType: schemas.UserResourceType,
Created: user.CreationDate.UTC(),
LastModified: user.ChangeDate.UTC(),
Version: strconv.FormatUint(user.Sequence, 10),
Location: buildLocation(ctx, h, user.ID),
},
}
}
func cascadingMemberships(memberships []*query.Membership) []*command.CascadingMembership {
cascades := make([]*command.CascadingMembership, len(memberships))
for i, membership := range memberships {
cascades[i] = &command.CascadingMembership{
UserID: membership.UserID,
ResourceOwner: membership.ResourceOwner,
IAM: cascadingIAMMembership(membership.IAM),
Org: cascadingOrgMembership(membership.Org),
Project: cascadingProjectMembership(membership.Project),
ProjectGrant: cascadingProjectGrantMembership(membership.ProjectGrant),
}
}
return cascades
}
func cascadingIAMMembership(membership *query.IAMMembership) *command.CascadingIAMMembership {
if membership == nil {
return nil
}
return &command.CascadingIAMMembership{IAMID: membership.IAMID}
}
func cascadingOrgMembership(membership *query.OrgMembership) *command.CascadingOrgMembership {
if membership == nil {
return nil
}
return &command.CascadingOrgMembership{OrgID: membership.OrgID}
}
func cascadingProjectMembership(membership *query.ProjectMembership) *command.CascadingProjectMembership {
if membership == nil {
return nil
}
return &command.CascadingProjectMembership{ProjectID: membership.ProjectID}
}
func cascadingProjectGrantMembership(membership *query.ProjectGrantMembership) *command.CascadingProjectGrantMembership {
if membership == nil {
return nil
}
return &command.CascadingProjectGrantMembership{ProjectID: membership.ProjectID, GrantID: membership.GrantID}
}
func userGrantsToIDs(userGrants []*query.UserGrant) []string {
converted := make([]string, len(userGrants))
for i, grant := range userGrants {
converted[i] = grant.ID
}
return converted
}

View File

@ -0,0 +1,259 @@
package resources
import (
"context"
"encoding/json"
"time"
// import timezone database to ensure it is available at runtime
// data is required to validate time zones.
_ "time/tzdata"
"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/domain"
"github.com/zitadel/zitadel/internal/query"
"github.com/zitadel/zitadel/internal/zerrors"
)
func (h *UsersHandler) queryMetadataForUser(ctx context.Context, id string) (map[metadata.ScopedKey][]byte, error) {
queries := h.buildMetadataQueries(ctx)
md, err := h.query.SearchUserMetadata(ctx, false, id, queries, false)
if err != nil {
return nil, err
}
metadataMap := make(map[metadata.ScopedKey][]byte, len(md.Metadata))
for _, entry := range md.Metadata {
metadataMap[metadata.ScopedKey(entry.Key)] = entry.Value
}
return metadataMap, nil
}
func (h *UsersHandler) buildMetadataQueries(ctx context.Context) *query.UserMetadataSearchQueries {
keyQueries := make([]query.SearchQuery, len(metadata.ScimUserRelevantMetadataKeys))
for i, key := range metadata.ScimUserRelevantMetadataKeys {
keyQueries[i] = buildMetadataKeyQuery(ctx, key)
}
queries := &query.UserMetadataSearchQueries{
SearchRequest: query.SearchRequest{},
Queries: []query.SearchQuery{query.Or(keyQueries...)},
}
return queries
}
func buildMetadataKeyQuery(ctx context.Context, key metadata.Key) query.SearchQuery {
scopedKey := metadata.ScopeKey(ctx, key)
q, err := query.NewUserMetadataKeySearchQuery(string(scopedKey), query.TextEquals)
if err != nil {
logging.Panic("Error build user metadata query for key " + key)
}
return q
}
func (h *UsersHandler) mapMetadataToDomain(ctx context.Context, user *ScimUser) (md []*domain.Metadata, skippedMetadata []string, err error) {
md = make([]*domain.Metadata, 0, len(metadata.ScimUserRelevantMetadataKeys))
for _, key := range metadata.ScimUserRelevantMetadataKeys {
var value []byte
value, err = getValueForMetadataKey(user, key)
if err != nil {
return
}
if len(value) > 0 {
md = append(md, &domain.Metadata{
Key: string(metadata.ScopeKey(ctx, key)),
Value: value,
})
} else {
skippedMetadata = append(skippedMetadata, string(metadata.ScopeKey(ctx, key)))
}
}
return
}
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:
val, err := json.Marshal(value)
if err != nil {
return nil, err
}
// null is considered no value
if len(val) == 4 && string(val) == "null" {
return nil, nil
}
return val, nil
// 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
}
func extractScalarMetadata(ctx context.Context, md map[metadata.ScopedKey][]byte, key metadata.Key) string {
val, ok := md[metadata.ScopeKey(ctx, key)]
if !ok {
return ""
}
return string(val)
}
func extractHttpURLMetadata(ctx context.Context, md map[metadata.ScopedKey][]byte, key metadata.Key) *schemas.HttpURL {
val, ok := md[metadata.ScopeKey(ctx, key)]
if !ok {
return nil
}
url, err := schemas.ParseHTTPURL(string(val))
if err != nil {
logging.OnError(err).Warn("Failed to parse scim url metadata for " + key)
return nil
}
return url
}
func extractJsonMetadata(ctx context.Context, md map[metadata.ScopedKey][]byte, key metadata.Key, v interface{}) error {
val, ok := md[metadata.ScopeKey(ctx, key)]
if !ok {
return nil
}
return json.Unmarshal(val, v)
}

View 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"
)

View 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)
}

View 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)
})
}
}

View 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()
}

View 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
}

View 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 ""
}
}

View 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())
}
})
}
}

105
internal/api/scim/server.go Normal file
View File

@ -0,0 +1,105 @@
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)
resourceRouter.Handle("/{id}", mw(handleResourceResponse(adapter.Get))).Methods(http.MethodGet)
resourceRouter.Handle("/{id}", mw(handleResourceResponse(adapter.Replace))).Methods(http.MethodPut)
resourceRouter.Handle("/{id}", mw(handleEmptyResponse(adapter.Delete))).Methods(http.MethodDelete)
}
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
}
}
func handleResourceResponse[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.ContentLocation, resource.Meta.Location)
err = json.NewEncoder(w).Encode(entity)
logging.OnError(err).Warn("scim json response encoding failed")
return nil
}
}
func handleEmptyResponse(next func(*http.Request) error) zhttp_middlware.HandlerFuncWithError {
return func(w http.ResponseWriter, r *http.Request) error {
err := next(r)
if err != nil {
return err
}
w.WriteHeader(http.StatusNoContent)
return nil
}
}

Some files were not shown because too many files have changed in this diff Show More