mirror of
https://github.com/zitadel/zitadel.git
synced 2025-04-27 03:30:51 +00:00
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:
commit
a53fc5f5fa
@ -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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
@ -110,24 +110,13 @@ PublicHostHeaders: # ZITADEL_PUBLICHOSTHEADERS
|
||||
WebAuthNName: ZITADEL # ZITADEL_WEBAUTHNNAME
|
||||
|
||||
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
|
||||
cockroach:
|
||||
Host: localhost # ZITADEL_DATABASE_COCKROACH_HOST
|
||||
Port: 26257 # ZITADEL_DATABASE_COCKROACH_PORT
|
||||
Database: zitadel # ZITADEL_DATABASE_COCKROACH_DATABASE
|
||||
MaxOpenConns: 15 # ZITADEL_DATABASE_COCKROACH_MAXOPENCONNS
|
||||
MaxIdleConns: 12 # ZITADEL_DATABASE_COCKROACH_MAXIDLECONNS
|
||||
MaxOpenConns: 5 # ZITADEL_DATABASE_COCKROACH_MAXOPENCONNS
|
||||
MaxIdleConns: 2 # ZITADEL_DATABASE_COCKROACH_MAXIDLECONNS
|
||||
MaxConnLifetime: 30m # ZITADEL_DATABASE_COCKROACH_MAXCONNLIFETIME
|
||||
MaxConnIdleTime: 5m # ZITADEL_DATABASE_COCKROACH_MAXCONNIDLETIME
|
||||
Options: "" # ZITADEL_DATABASE_COCKROACH_OPTIONS
|
||||
@ -590,6 +579,11 @@ SAML:
|
||||
# Company: ZITADEL # ZITADEL_SAML_PROVIDERCONFIG_CONTACTPERSON_COMPANY
|
||||
# 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:
|
||||
LanguageCookieName: zitadel.login.lang # ZITADEL_LOGIN_LANGUAGECOOKIENAME
|
||||
CSRFCookieName: zitadel.login.csrf # ZITADEL_LOGIN_CSRFCOOKIENAME
|
||||
@ -608,6 +602,9 @@ Console:
|
||||
# 168h is 7 days, one week
|
||||
SharedMaxAge: 168h # ZITADEL_CONSOLE_LONGCACHE_SHAREDMAXAGE
|
||||
InstanceManagementURL: "" # ZITADEL_CONSOLE_INSTANCEMANAGEMENTURL
|
||||
PostHog:
|
||||
URL: "" # ZITADEL_CONSOLE_POSTHOG_URL
|
||||
Token: "" # ZITADEL_CONSOLE_POSTHOG_TOKEN
|
||||
|
||||
EncryptionKeys:
|
||||
DomainVerification:
|
||||
@ -1124,6 +1121,7 @@ DefaultInstance:
|
||||
LoginDefaultOrg: true # ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINDEFAULTORG
|
||||
# TriggerIntrospectionProjections: false # ZITADEL_DEFAULTINSTANCE_FEATURES_TRIGGERINTROSPECTIONPROJECTIONS
|
||||
# LegacyIntrospection: false # ZITADEL_DEFAULTINSTANCE_FEATURES_LEGACYINTROSPECTION
|
||||
# PermissionCheckV2: false # ZITADEL_DEFAULTINSTANCE_FEATURES_PERMISSIONCHECKV2
|
||||
Limits:
|
||||
# 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.
|
||||
@ -1187,6 +1185,9 @@ InternalAuthZ:
|
||||
# Configure the RolePermissionMappings by environment variable using JSON notation:
|
||||
# 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.
|
||||
#
|
||||
# Warning: RolePermissionMappings are synhronized to the database.
|
||||
# Changes here will only be applied after running `zitadel setup` or `zitadel start-from-setup`.
|
||||
RolePermissionMappings:
|
||||
- Role: "SYSTEM_OWNER"
|
||||
Permissions:
|
||||
|
@ -9,7 +9,6 @@ import (
|
||||
"github.com/zitadel/logging"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/database"
|
||||
"github.com/zitadel/zitadel/internal/database/dialect"
|
||||
)
|
||||
|
||||
var (
|
||||
@ -79,7 +78,7 @@ func initialise(ctx context.Context, config database.Config, steps ...func(conte
|
||||
return err
|
||||
}
|
||||
|
||||
db, err := database.Connect(config, true, dialect.DBPurposeQuery)
|
||||
db, err := database.Connect(config, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -11,7 +11,6 @@ import (
|
||||
"github.com/zitadel/logging"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/database"
|
||||
"github.com/zitadel/zitadel/internal/database/dialect"
|
||||
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 {
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
|
@ -12,7 +12,6 @@ import (
|
||||
"github.com/zitadel/zitadel/internal/crypto"
|
||||
cryptoDB "github.com/zitadel/zitadel/internal/crypto/database"
|
||||
"github.com/zitadel/zitadel/internal/database"
|
||||
"github.com/zitadel/zitadel/internal/database/dialect"
|
||||
"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) {
|
||||
db, err := database.Connect(config, false, dialect.DBPurposeQuery)
|
||||
db, err := database.Connect(config, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -12,7 +12,6 @@ import (
|
||||
"github.com/zitadel/logging"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/database"
|
||||
"github.com/zitadel/zitadel/internal/database/dialect"
|
||||
)
|
||||
|
||||
func authCmd() *cobra.Command {
|
||||
@ -34,11 +33,11 @@ Only auth requests are mirrored`,
|
||||
}
|
||||
|
||||
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")
|
||||
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")
|
||||
defer destClient.Close()
|
||||
|
||||
|
@ -14,7 +14,6 @@ import (
|
||||
"github.com/zitadel/logging"
|
||||
|
||||
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/v2/database"
|
||||
"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) {
|
||||
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")
|
||||
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")
|
||||
defer destClient.Close()
|
||||
|
||||
|
@ -30,7 +30,6 @@ import (
|
||||
"github.com/zitadel/zitadel/internal/config/systemdefaults"
|
||||
crypto_db "github.com/zitadel/zitadel/internal/crypto/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/eventstore"
|
||||
old_es "github.com/zitadel/zitadel/internal/eventstore/repository/sql"
|
||||
@ -106,7 +105,7 @@ func projections(
|
||||
) {
|
||||
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")
|
||||
|
||||
keyStorage, err := crypto_db.NewKeyStorage(client, masterKey)
|
||||
@ -119,9 +118,7 @@ func projections(
|
||||
logging.OnError(err).Fatal("unable create static storage")
|
||||
|
||||
config.Eventstore.Querier = old_es.NewCRDB(client)
|
||||
esPusherDBClient, err := database.Connect(config.Destination, false, dialect.DBPurposeEventPusher)
|
||||
logging.OnError(err).Fatal("unable to connect eventstore push client")
|
||||
config.Eventstore.Pusher = new_es.NewEventstore(esPusherDBClient)
|
||||
config.Eventstore.Pusher = new_es.NewEventstore(client)
|
||||
es := eventstore.NewEventstore(config.Eventstore)
|
||||
esV4 := es_v4.NewEventstoreFromOne(es_v4_pg.New(client, &es_v4_pg.Config{
|
||||
MaxRetries: config.Eventstore.MaxRetries,
|
||||
|
@ -12,7 +12,6 @@ import (
|
||||
"github.com/zitadel/logging"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/database"
|
||||
"github.com/zitadel/zitadel/internal/database/dialect"
|
||||
)
|
||||
|
||||
func systemCmd() *cobra.Command {
|
||||
@ -34,11 +33,11 @@ Only keys and assets are mirrored`,
|
||||
}
|
||||
|
||||
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")
|
||||
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")
|
||||
defer destClient.Close()
|
||||
|
||||
|
@ -13,7 +13,6 @@ import (
|
||||
|
||||
cryptoDatabase "github.com/zitadel/zitadel/internal/crypto/database"
|
||||
"github.com/zitadel/zitadel/internal/database"
|
||||
"github.com/zitadel/zitadel/internal/database/dialect"
|
||||
"github.com/zitadel/zitadel/internal/query/projection"
|
||||
)
|
||||
|
||||
@ -37,11 +36,11 @@ var schemas = []string{
|
||||
}
|
||||
|
||||
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")
|
||||
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")
|
||||
defer destClient.Close()
|
||||
|
||||
|
@ -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
111
cmd/setup/45.go
Normal 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
79
cmd/setup/45.sql
Normal 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
39
cmd/setup/46.go
Normal 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"
|
||||
}
|
6
cmd/setup/46/01-role_permissions_view.sql
Normal file
6
cmd/setup/46/01-role_permissions_view.sql
Normal 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';
|
6
cmd/setup/46/02-instance_orgs_view.sql
Normal file
6
cmd/setup/46/02-instance_orgs_view.sql
Normal 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';
|
6
cmd/setup/46/03-instance_members_view.sql
Normal file
6
cmd/setup/46/03-instance_members_view.sql
Normal 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';
|
6
cmd/setup/46/04-org_members_view.sql
Normal file
6
cmd/setup/46/04-org_members_view.sql
Normal 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';
|
6
cmd/setup/46/05-project_members_view.sql
Normal file
6
cmd/setup/46/05-project_members_view.sql
Normal 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';
|
50
cmd/setup/46/06-permitted_orgs_function.sql
Normal file
50
cmd/setup/46/06-permitted_orgs_function.sql
Normal 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;
|
||||
$$;
|
@ -8,7 +8,6 @@ import (
|
||||
"github.com/zitadel/logging"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/database"
|
||||
"github.com/zitadel/zitadel/internal/database/dialect"
|
||||
"github.com/zitadel/zitadel/internal/eventstore"
|
||||
old_es "github.com/zitadel/zitadel/internal/eventstore/repository/sql"
|
||||
new_es "github.com/zitadel/zitadel/internal/eventstore/v3"
|
||||
@ -32,13 +31,11 @@ func Cleanup(config *Config) {
|
||||
|
||||
logging.Info("cleanup started")
|
||||
|
||||
queryDBClient, err := database.Connect(config.Database, false, dialect.DBPurposeQuery)
|
||||
logging.OnError(err).Fatal("unable to connect to database")
|
||||
esPusherDBClient, err := database.Connect(config.Database, false, dialect.DBPurposeEventPusher)
|
||||
dbClient, err := database.Connect(config.Database, false)
|
||||
logging.OnError(err).Fatal("unable to connect to database")
|
||||
|
||||
config.Eventstore.Pusher = new_es.NewEventstore(esPusherDBClient)
|
||||
config.Eventstore.Querier = old_es.NewCRDB(queryDBClient)
|
||||
config.Eventstore.Pusher = new_es.NewEventstore(dbClient)
|
||||
config.Eventstore.Querier = old_es.NewCRDB(dbClient)
|
||||
es := eventstore.NewEventstore(config.Eventstore)
|
||||
|
||||
step, err := migration.LastStuckStep(ctx, es)
|
||||
|
@ -87,6 +87,9 @@ func MustNewConfig(v *viper.Viper) *Config {
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@ -130,6 +133,8 @@ type Steps struct {
|
||||
s42Apps7OIDCConfigsLoginVersion *Apps7OIDCConfigsLoginVersion
|
||||
s43CreateFieldsDomainIndex *CreateFieldsDomainIndex
|
||||
s44ReplaceCurrentSequencesIndex *ReplaceCurrentSequencesIndex
|
||||
s45CorrectProjectOwners *CorrectProjectOwners
|
||||
s46InitPermissionFunctions *InitPermissionFunctions
|
||||
}
|
||||
|
||||
func MustNewSteps(v *viper.Viper) *Steps {
|
||||
|
51
cmd/setup/fill_fields.go
Normal file
51
cmd/setup/fill_fields.go
Normal 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
|
||||
}
|
@ -26,9 +26,9 @@ import (
|
||||
"github.com/zitadel/zitadel/internal/command"
|
||||
cryptoDB "github.com/zitadel/zitadel/internal/crypto/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/eventstore"
|
||||
"github.com/zitadel/zitadel/internal/eventstore/handler/v2"
|
||||
old_es "github.com/zitadel/zitadel/internal/eventstore/repository/sql"
|
||||
new_es "github.com/zitadel/zitadel/internal/eventstore/v3"
|
||||
"github.com/zitadel/zitadel/internal/i18n"
|
||||
@ -102,26 +102,22 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string)
|
||||
|
||||
i18n.MustLoadSupportedLanguagesFromDir()
|
||||
|
||||
queryDBClient, err := database.Connect(config.Database, false, dialect.DBPurposeQuery)
|
||||
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)
|
||||
dbClient, err := database.Connect(config.Database, false)
|
||||
logging.OnError(err).Fatal("unable to connect to database")
|
||||
|
||||
config.Eventstore.Querier = old_es.NewCRDB(queryDBClient)
|
||||
esV3 := new_es.NewEventstore(esPusherDBClient)
|
||||
config.Eventstore.Querier = old_es.NewCRDB(dbClient)
|
||||
esV3 := new_es.NewEventstore(dbClient)
|
||||
config.Eventstore.Pusher = esV3
|
||||
config.Eventstore.Searcher = esV3
|
||||
eventstoreClient := eventstore.NewEventstore(config.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,
|
||||
}))
|
||||
|
||||
steps.s1ProjectionTable = &ProjectionTable{dbClient: queryDBClient.DB}
|
||||
steps.s2AssetsTable = &AssetTable{dbClient: queryDBClient.DB}
|
||||
steps.s1ProjectionTable = &ProjectionTable{dbClient: dbClient.DB}
|
||||
steps.s2AssetsTable = &AssetTable{dbClient: dbClient.DB}
|
||||
|
||||
steps.FirstInstance.Skip = config.ForMirror || steps.FirstInstance.Skip
|
||||
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.oidcEncryptionKey = config.EncryptionKeys.OIDC
|
||||
steps.FirstInstance.masterKey = masterKey
|
||||
steps.FirstInstance.db = queryDBClient
|
||||
steps.FirstInstance.db = dbClient
|
||||
steps.FirstInstance.es = eventstoreClient
|
||||
steps.FirstInstance.defaults = config.SystemDefaults
|
||||
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.externalPort = config.ExternalPort
|
||||
|
||||
steps.s5LastFailed = &LastFailed{dbClient: queryDBClient.DB}
|
||||
steps.s6OwnerRemoveColumns = &OwnerRemoveColumns{dbClient: queryDBClient.DB}
|
||||
steps.s7LogstoreTables = &LogstoreTables{dbClient: queryDBClient.DB, username: config.Database.Username(), dbType: config.Database.Type()}
|
||||
steps.s8AuthTokens = &AuthTokenIndexes{dbClient: queryDBClient}
|
||||
steps.CorrectCreationDate.dbClient = esPusherDBClient
|
||||
steps.s12AddOTPColumns = &AddOTPColumns{dbClient: queryDBClient}
|
||||
steps.s13FixQuotaProjection = &FixQuotaConstraints{dbClient: queryDBClient}
|
||||
steps.s14NewEventsTable = &NewEventsTable{dbClient: esPusherDBClient}
|
||||
steps.s15CurrentStates = &CurrentProjectionState{dbClient: queryDBClient}
|
||||
steps.s16UniqueConstraintsLower = &UniqueConstraintToLower{dbClient: queryDBClient}
|
||||
steps.s17AddOffsetToUniqueConstraints = &AddOffsetToCurrentStates{dbClient: queryDBClient}
|
||||
steps.s18AddLowerFieldsToLoginNames = &AddLowerFieldsToLoginNames{dbClient: queryDBClient}
|
||||
steps.s19AddCurrentStatesIndex = &AddCurrentSequencesIndex{dbClient: queryDBClient}
|
||||
steps.s20AddByUserSessionIndex = &AddByUserIndexToSession{dbClient: queryDBClient}
|
||||
steps.s21AddBlockFieldToLimits = &AddBlockFieldToLimits{dbClient: queryDBClient}
|
||||
steps.s22ActiveInstancesIndex = &ActiveInstanceEvents{dbClient: queryDBClient}
|
||||
steps.s23CorrectGlobalUniqueConstraints = &CorrectGlobalUniqueConstraints{dbClient: esPusherDBClient}
|
||||
steps.s24AddActorToAuthTokens = &AddActorToAuthTokens{dbClient: queryDBClient}
|
||||
steps.s25User11AddLowerFieldsToVerifiedEmail = &User11AddLowerFieldsToVerifiedEmail{dbClient: esPusherDBClient}
|
||||
steps.s26AuthUsers3 = &AuthUsers3{dbClient: esPusherDBClient}
|
||||
steps.s27IDPTemplate6SAMLNameIDFormat = &IDPTemplate6SAMLNameIDFormat{dbClient: esPusherDBClient}
|
||||
steps.s28AddFieldTable = &AddFieldTable{dbClient: esPusherDBClient}
|
||||
steps.s5LastFailed = &LastFailed{dbClient: dbClient.DB}
|
||||
steps.s6OwnerRemoveColumns = &OwnerRemoveColumns{dbClient: dbClient.DB}
|
||||
steps.s7LogstoreTables = &LogstoreTables{dbClient: dbClient.DB, username: config.Database.Username(), dbType: config.Database.Type()}
|
||||
steps.s8AuthTokens = &AuthTokenIndexes{dbClient: dbClient}
|
||||
steps.CorrectCreationDate.dbClient = dbClient
|
||||
steps.s12AddOTPColumns = &AddOTPColumns{dbClient: dbClient}
|
||||
steps.s13FixQuotaProjection = &FixQuotaConstraints{dbClient: dbClient}
|
||||
steps.s14NewEventsTable = &NewEventsTable{dbClient: dbClient}
|
||||
steps.s15CurrentStates = &CurrentProjectionState{dbClient: dbClient}
|
||||
steps.s16UniqueConstraintsLower = &UniqueConstraintToLower{dbClient: dbClient}
|
||||
steps.s17AddOffsetToUniqueConstraints = &AddOffsetToCurrentStates{dbClient: dbClient}
|
||||
steps.s18AddLowerFieldsToLoginNames = &AddLowerFieldsToLoginNames{dbClient: dbClient}
|
||||
steps.s19AddCurrentStatesIndex = &AddCurrentSequencesIndex{dbClient: dbClient}
|
||||
steps.s20AddByUserSessionIndex = &AddByUserIndexToSession{dbClient: dbClient}
|
||||
steps.s21AddBlockFieldToLimits = &AddBlockFieldToLimits{dbClient: dbClient}
|
||||
steps.s22ActiveInstancesIndex = &ActiveInstanceEvents{dbClient: dbClient}
|
||||
steps.s23CorrectGlobalUniqueConstraints = &CorrectGlobalUniqueConstraints{dbClient: dbClient}
|
||||
steps.s24AddActorToAuthTokens = &AddActorToAuthTokens{dbClient: dbClient}
|
||||
steps.s25User11AddLowerFieldsToVerifiedEmail = &User11AddLowerFieldsToVerifiedEmail{dbClient: dbClient}
|
||||
steps.s26AuthUsers3 = &AuthUsers3{dbClient: dbClient}
|
||||
steps.s27IDPTemplate6SAMLNameIDFormat = &IDPTemplate6SAMLNameIDFormat{dbClient: dbClient}
|
||||
steps.s28AddFieldTable = &AddFieldTable{dbClient: dbClient}
|
||||
steps.s29FillFieldsForProjectGrant = &FillFieldsForProjectGrant{eventstore: eventstoreClient}
|
||||
steps.s30FillFieldsForOrgDomainVerified = &FillFieldsForOrgDomainVerified{eventstore: eventstoreClient}
|
||||
steps.s31AddAggregateIndexToFields = &AddAggregateIndexToFields{dbClient: esPusherDBClient}
|
||||
steps.s32AddAuthSessionID = &AddAuthSessionID{dbClient: esPusherDBClient}
|
||||
steps.s33SMSConfigs3TwilioAddVerifyServiceSid = &SMSConfigs3TwilioAddVerifyServiceSid{dbClient: esPusherDBClient}
|
||||
steps.s34AddCacheSchema = &AddCacheSchema{dbClient: queryDBClient}
|
||||
steps.s35AddPositionToIndexEsWm = &AddPositionToIndexEsWm{dbClient: esPusherDBClient}
|
||||
steps.s36FillV2Milestones = &FillV3Milestones{dbClient: queryDBClient, eventstore: eventstoreClient}
|
||||
steps.s37Apps7OIDConfigsBackChannelLogoutURI = &Apps7OIDConfigsBackChannelLogoutURI{dbClient: esPusherDBClient}
|
||||
steps.s38BackChannelLogoutNotificationStart = &BackChannelLogoutNotificationStart{dbClient: esPusherDBClient, esClient: eventstoreClient}
|
||||
steps.s40InitPushFunc = &InitPushFunc{dbClient: esPusherDBClient}
|
||||
steps.s42Apps7OIDCConfigsLoginVersion = &Apps7OIDCConfigsLoginVersion{dbClient: esPusherDBClient}
|
||||
steps.s43CreateFieldsDomainIndex = &CreateFieldsDomainIndex{dbClient: queryDBClient}
|
||||
steps.s44ReplaceCurrentSequencesIndex = &ReplaceCurrentSequencesIndex{dbClient: esPusherDBClient}
|
||||
steps.s31AddAggregateIndexToFields = &AddAggregateIndexToFields{dbClient: dbClient}
|
||||
steps.s32AddAuthSessionID = &AddAuthSessionID{dbClient: dbClient}
|
||||
steps.s33SMSConfigs3TwilioAddVerifyServiceSid = &SMSConfigs3TwilioAddVerifyServiceSid{dbClient: dbClient}
|
||||
steps.s34AddCacheSchema = &AddCacheSchema{dbClient: dbClient}
|
||||
steps.s35AddPositionToIndexEsWm = &AddPositionToIndexEsWm{dbClient: dbClient}
|
||||
steps.s36FillV2Milestones = &FillV3Milestones{dbClient: dbClient, eventstore: eventstoreClient}
|
||||
steps.s37Apps7OIDConfigsBackChannelLogoutURI = &Apps7OIDConfigsBackChannelLogoutURI{dbClient: dbClient}
|
||||
steps.s38BackChannelLogoutNotificationStart = &BackChannelLogoutNotificationStart{dbClient: dbClient, esClient: eventstoreClient}
|
||||
steps.s40InitPushFunc = &InitPushFunc{dbClient: dbClient}
|
||||
steps.s42Apps7OIDCConfigsLoginVersion = &Apps7OIDCConfigsLoginVersion{dbClient: dbClient}
|
||||
steps.s43CreateFieldsDomainIndex = &CreateFieldsDomainIndex{dbClient: dbClient}
|
||||
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")
|
||||
|
||||
repeatableSteps := []migration.RepeatableMigration{
|
||||
@ -192,8 +190,16 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string)
|
||||
&DeleteStaleOrgFields{
|
||||
eventstore: eventstoreClient,
|
||||
},
|
||||
&FillFieldsForInstanceDomains{
|
||||
&RepeatableFillFields{
|
||||
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.s38BackChannelLogoutNotificationStart,
|
||||
steps.s44ReplaceCurrentSequencesIndex,
|
||||
steps.s45CorrectProjectOwners,
|
||||
steps.s46InitPermissionFunctions,
|
||||
} {
|
||||
mustExecuteMigration(ctx, eventstoreClient, step, "migration failed")
|
||||
}
|
||||
@ -256,8 +264,8 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string)
|
||||
ctx,
|
||||
eventstoreClient,
|
||||
eventstoreV4,
|
||||
queryDBClient,
|
||||
projectionDBClient,
|
||||
dbClient,
|
||||
dbClient,
|
||||
masterKey,
|
||||
config,
|
||||
)
|
||||
|
134
cmd/setup/sync_role_permissions.go
Normal file
134
cmd/setup/sync_role_permissions.go
Normal 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
|
||||
}
|
52
cmd/setup/sync_role_permissions.sql
Normal file
52
cmd/setup/sync_role_permissions.sql
Normal 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
|
||||
;
|
@ -15,6 +15,7 @@ import (
|
||||
"github.com/zitadel/zitadel/internal/api/http/middleware"
|
||||
"github.com/zitadel/zitadel/internal/api/oidc"
|
||||
"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/login"
|
||||
auth_es "github.com/zitadel/zitadel/internal/auth/repository/eventsourcing"
|
||||
@ -60,6 +61,7 @@ type Config struct {
|
||||
UserAgentCookie *middleware.UserAgentCookieConfig
|
||||
OIDC oidc.Config
|
||||
SAML saml.Config
|
||||
SCIM scim_config.Config
|
||||
Login login.Config
|
||||
Console console.Config
|
||||
AssetStorage static_config.AssetStorageConfig
|
||||
@ -125,5 +127,8 @@ func MustNewConfig(v *viper.Viper) *Config {
|
||||
id.Configure(config.Machine)
|
||||
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
|
||||
}
|
||||
|
@ -63,6 +63,8 @@ import (
|
||||
"github.com/zitadel/zitadel/internal/api/oidc"
|
||||
"github.com/zitadel/zitadel/internal/api/robots_txt"
|
||||
"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/path"
|
||||
"github.com/zitadel/zitadel/internal/api/ui/login"
|
||||
@ -75,7 +77,6 @@ import (
|
||||
"github.com/zitadel/zitadel/internal/crypto"
|
||||
cryptoDB "github.com/zitadel/zitadel/internal/crypto/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/eventstore"
|
||||
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()
|
||||
|
||||
queryDBClient, err := database.Connect(config.Database, false, dialect.DBPurposeQuery)
|
||||
dbClient, err := database.Connect(config.Database, false)
|
||||
if err != nil {
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
config.Eventstore.Pusher = new_es.NewEventstore(esPusherDBClient)
|
||||
config.Eventstore.Searcher = new_es.NewEventstore(queryDBClient)
|
||||
config.Eventstore.Querier = old_es.NewCRDB(queryDBClient)
|
||||
config.Eventstore.Pusher = new_es.NewEventstore(dbClient)
|
||||
config.Eventstore.Searcher = new_es.NewEventstore(dbClient)
|
||||
config.Eventstore.Querier = old_es.NewCRDB(dbClient)
|
||||
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,
|
||||
}))
|
||||
|
||||
sessionTokenVerifier := internal_authz.SessionTokenVerifier(keys.OIDC)
|
||||
cacheConnectors, err := connector.StartConnectors(config.Caches, queryDBClient)
|
||||
cacheConnectors, err := connector.StartConnectors(config.Caches, dbClient)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to start caches: %w", err)
|
||||
}
|
||||
@ -188,8 +181,8 @@ func startZitadel(ctx context.Context, config *Config, masterKey string, server
|
||||
ctx,
|
||||
eventstoreClient,
|
||||
eventstoreV4.Querier,
|
||||
queryDBClient,
|
||||
projectionDBClient,
|
||||
dbClient,
|
||||
dbClient,
|
||||
cacheConnectors,
|
||||
config.Projections,
|
||||
config.SystemDefaults,
|
||||
@ -213,7 +206,7 @@ func startZitadel(ctx context.Context, config *Config, masterKey string, server
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
|
||||
storage, err := config.AssetStorage.NewStorage(queryDBClient.DB)
|
||||
storage, err := config.AssetStorage.NewStorage(dbClient.DB)
|
||||
if err != nil {
|
||||
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 {
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
@ -295,7 +288,7 @@ func startZitadel(ctx context.Context, config *Config, masterKey string, server
|
||||
keys.SMS,
|
||||
keys.OIDC,
|
||||
config.OIDC.DefaultBackChannelLogoutLifetime,
|
||||
queryDBClient,
|
||||
dbClient,
|
||||
)
|
||||
notification.Start(ctx)
|
||||
|
||||
@ -311,7 +304,7 @@ func startZitadel(ctx context.Context, config *Config, masterKey string, server
|
||||
commands,
|
||||
queries,
|
||||
eventstoreClient,
|
||||
queryDBClient,
|
||||
dbClient,
|
||||
config,
|
||||
storage,
|
||||
authZRepo,
|
||||
@ -331,7 +324,7 @@ func startZitadel(ctx context.Context, config *Config, masterKey string, server
|
||||
if server != nil {
|
||||
server <- &Server{
|
||||
Config: config,
|
||||
DB: queryDBClient,
|
||||
DB: dbClient,
|
||||
KeyStorage: keyStorage,
|
||||
Keys: keys,
|
||||
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 {
|
||||
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
|
||||
}
|
||||
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 {
|
||||
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
|
||||
}
|
||||
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(
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to start console: %w", err)
|
||||
|
@ -26,7 +26,7 @@ Please check below the matrix for an overview where which scope is asserted.
|
||||
| jti | No | Yes | No | When JWT |
|
||||
| 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 |
|
||||
| nbf | No | Yes | Yes | When JWT |
|
||||
| nbf | No | Yes | No | When JWT |
|
||||
| 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_verified | When requested | When requested | When requested and response_type `id_token` | No |
|
||||
|
@ -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).
|
||||
|
||||
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.
|
||||
|
||||
[Create User API Documentation](/docs/apis/resources/user_service_v2/user-service-add-human-user)
|
||||
@ -181,8 +181,8 @@ curl --request POST \
|
||||
"idpLinks": [
|
||||
{
|
||||
"idpId": "218528353504723201",
|
||||
"idpExternalId": "111392805975715856637",
|
||||
"displayName": "Minnie Mouse"
|
||||
"userId": "111392805975715856637",
|
||||
"userName": "Minnie Mouse"
|
||||
}
|
||||
]
|
||||
}'
|
||||
@ -205,8 +205,8 @@ curl --request POST \
|
||||
--data '{
|
||||
"idpLink": {
|
||||
"idpId": "218528353504723201",
|
||||
"idpExternalId": "1113928059757158566371",
|
||||
"displayName": "Minnie Mouse"
|
||||
"userId": "1113928059757158566371",
|
||||
"userName": "Minnie Mouse"
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
@ -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.
|
||||
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.
|
||||
|
||||

|
||||
|
207
docs/docs/guides/integrate/login/hosted-login.mdx
Normal file
207
docs/docs/guides/integrate/login/hosted-login.mdx
Normal 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
|
||||
|
||||

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

|
||||
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.) can’t 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*
|
@ -1,6 +1,6 @@
|
||||
---
|
||||
title: Login users into your application with a hosted or custom login UI
|
||||
sidebar_label: Hosted vs. Custom Login UI
|
||||
title: Log users into your application with different authentication options
|
||||
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.
|
||||
@ -25,6 +25,8 @@ The identity provider is not part of the original application, but a standalone
|
||||
The user will authenticate using their credentials.
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
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.
|
||||
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.
|
||||
:::
|
||||
|
||||
#### Key features of the Session API
|
||||
### Key features of the Session 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
|
||||
|
||||
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.
|
||||
ZITADEL provides a hosted single-sign-on page for secure user authentication within your applications.
|
||||
This centralized authentication interface simplifies application integration by offering a ready-to-use login experience.
|
||||
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
|
||||
|
||||
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
|
||||
|
||||

|
||||
|
||||
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.
|
||||
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.
|
||||
- **Standard authentication flows suffice:** Your application doesn't necessitate complex or unique authentication processes.
|
||||
- **OIDC or SAML are suitable:** Your application integrates seamlessly with industry-standard protocols.
|
||||
- **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.
|
||||
|
||||
## Build a custom Login UI to authenticate users
|
||||
|
||||
|
@ -43,11 +43,10 @@ dotnet add package Zitadel.Api
|
||||
### 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.
|
||||
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 apiProject you will find in the ZITADEL project in the first organization of your instance.
|
||||
|
||||
```csharp
|
||||
// This file contains two examples:
|
||||
@ -66,7 +65,8 @@ var client = Clients.AuthService(new(apiUrl, ITokenProvider.Static(personalAcces
|
||||
var result = await client.GetMyUserAsync(new());
|
||||
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(
|
||||
@"
|
||||
{
|
||||
|
26
docs/docs/support/advisory/a10014.md
Normal file
26
docs/docs/support/advisory/a10014.md
Normal 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.
|
@ -214,6 +214,18 @@ We understand that these advisories may include breaking changes, and we aim to
|
||||
<td>-</td>
|
||||
<td>2024-12-09</td>
|
||||
</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>
|
||||
|
||||
## Subscribe to our Mailing List
|
||||
|
@ -206,6 +206,31 @@ module.exports = {
|
||||
},
|
||||
items: [
|
||||
"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",
|
||||
label: "OpenID Connect",
|
||||
|
BIN
docs/static/img/guides/integrate/login/login-v2-app-config.png
vendored
Normal file
BIN
docs/static/img/guides/integrate/login/login-v2-app-config.png
vendored
Normal file
Binary file not shown.
After Width: | Height: | Size: 365 KiB |
@ -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/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/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
4
go.mod
@ -28,6 +28,8 @@ require (
|
||||
github.com/go-jose/go-jose/v4 v4.0.4
|
||||
github.com/go-ldap/ldap/v3 v3.4.8
|
||||
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/mux v1.8.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-sql-driver/mysql v1.7.1 // 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/v5 v5.2.1 // 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/pprof v0.0.0-20240528025155-186aa0362fba // indirect
|
||||
github.com/google/s2a-go v0.1.7 // indirect
|
||||
|
@ -7,6 +7,11 @@ func NewMockContext(instanceID, orgID, userID string) context.Context {
|
||||
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 {
|
||||
ctx := context.WithValue(context.Background(), dataKey, CtxData{UserID: userID, OrgID: orgID})
|
||||
ctx = context.WithValue(ctx, instanceKey, &instance{id: instanceID})
|
||||
|
@ -29,6 +29,7 @@ func systemFeaturesToCommand(req *feature_pb.SetSystemFeaturesRequest) (*command
|
||||
DisableUserTokenEvent: req.DisableUserTokenEvent,
|
||||
EnableBackChannelLogout: req.EnableBackChannelLogout,
|
||||
LoginV2: loginV2,
|
||||
PermissionCheckV2: req.PermissionCheckV2,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@ -46,6 +47,7 @@ func systemFeaturesToPb(f *query.SystemFeatures) *feature_pb.GetSystemFeaturesRe
|
||||
DisableUserTokenEvent: featureSourceToFlagPb(&f.DisableUserTokenEvent),
|
||||
EnableBackChannelLogout: featureSourceToFlagPb(&f.EnableBackChannelLogout),
|
||||
LoginV2: loginV2ToLoginV2FlagPb(f.LoginV2),
|
||||
PermissionCheckV2: featureSourceToFlagPb(&f.PermissionCheckV2),
|
||||
}
|
||||
}
|
||||
|
||||
@ -68,6 +70,7 @@ func instanceFeaturesToCommand(req *feature_pb.SetInstanceFeaturesRequest) (*com
|
||||
DisableUserTokenEvent: req.DisableUserTokenEvent,
|
||||
EnableBackChannelLogout: req.EnableBackChannelLogout,
|
||||
LoginV2: loginV2,
|
||||
PermissionCheckV2: req.PermissionCheckV2,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@ -87,6 +90,7 @@ func instanceFeaturesToPb(f *query.InstanceFeatures) *feature_pb.GetInstanceFeat
|
||||
DisableUserTokenEvent: featureSourceToFlagPb(&f.DisableUserTokenEvent),
|
||||
EnableBackChannelLogout: featureSourceToFlagPb(&f.EnableBackChannelLogout),
|
||||
LoginV2: loginV2ToLoginV2FlagPb(f.LoginV2),
|
||||
PermissionCheckV2: featureSourceToFlagPb(&f.PermissionCheckV2),
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -101,6 +101,10 @@ func Test_systemFeaturesToPb(t *testing.T) {
|
||||
BaseURI: &url.URL{Scheme: "https", Host: "login.com"},
|
||||
},
|
||||
},
|
||||
PermissionCheckV2: query.FeatureSource[bool]{
|
||||
Level: feature.LevelSystem,
|
||||
Value: true,
|
||||
},
|
||||
}
|
||||
want := &feature_pb.GetSystemFeaturesResponse{
|
||||
Details: &object.Details{
|
||||
@ -153,6 +157,10 @@ func Test_systemFeaturesToPb(t *testing.T) {
|
||||
BaseUri: gu.Ptr("https://login.com"),
|
||||
Source: feature_pb.Source_SOURCE_SYSTEM,
|
||||
},
|
||||
PermissionCheckV2: &feature_pb.FeatureFlag{
|
||||
Enabled: true,
|
||||
Source: feature_pb.Source_SOURCE_SYSTEM,
|
||||
},
|
||||
}
|
||||
got := systemFeaturesToPb(arg)
|
||||
assert.Equal(t, want, got)
|
||||
@ -252,6 +260,10 @@ func Test_instanceFeaturesToPb(t *testing.T) {
|
||||
BaseURI: &url.URL{Scheme: "https", Host: "login.com"},
|
||||
},
|
||||
},
|
||||
PermissionCheckV2: query.FeatureSource[bool]{
|
||||
Level: feature.LevelInstance,
|
||||
Value: true,
|
||||
},
|
||||
}
|
||||
want := &feature_pb.GetInstanceFeaturesResponse{
|
||||
Details: &object.Details{
|
||||
@ -312,6 +324,10 @@ func Test_instanceFeaturesToPb(t *testing.T) {
|
||||
BaseUri: gu.Ptr("https://login.com"),
|
||||
Source: feature_pb.Source_SOURCE_INSTANCE,
|
||||
},
|
||||
PermissionCheckV2: &feature_pb.FeatureFlag{
|
||||
Enabled: true,
|
||||
Source: feature_pb.Source_SOURCE_INSTANCE,
|
||||
},
|
||||
}
|
||||
got := instanceFeaturesToPb(arg)
|
||||
assert.Equal(t, want, got)
|
||||
|
@ -34,6 +34,7 @@ func MemberToPb(assetAPIPrefix string, m *query.Member) *member_pb.Member {
|
||||
m.ChangeDate,
|
||||
m.ResourceOwner,
|
||||
),
|
||||
UserResourceOwner: m.UserResourceOwner,
|
||||
}
|
||||
}
|
||||
|
||||
|
714
internal/api/grpc/session/v2/integration_test/query_test.go
Normal file
714
internal/api/grpc/session/v2/integration_test/query_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
74
internal/api/grpc/session/v2/integration_test/server_test.go
Normal file
74
internal/api/grpc/session/v2/integration_test/server_test.go
Normal 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
|
||||
}
|
@ -5,7 +5,6 @@ package session_test
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@ -14,7 +13,6 @@ import (
|
||||
"github.com/pquerna/otp/totp"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/zitadel/logging"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/metadata"
|
||||
"google.golang.org/grpc/status"
|
||||
@ -29,63 +27,7 @@ import (
|
||||
"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.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 {
|
||||
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 {
|
||||
t.Helper()
|
||||
require.NotEmpty(t, id)
|
||||
require.NotEmpty(t, token)
|
||||
@ -96,15 +38,25 @@ func verifyCurrentSession(t testing.TB, id, token string, sequence uint64, windo
|
||||
})
|
||||
require.NoError(t, err)
|
||||
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.GetChangeDate().AsTime(), time.Now().Add(-window), time.Now().Add(window))
|
||||
assert.Equal(t, sequence, s.GetSequence())
|
||||
assert.Equal(t, metadata, s.GetMetadata())
|
||||
assert.Equal(t, want.Sequence, s.GetSequence())
|
||||
assert.Equal(t, want.Metadata, s.GetMetadata())
|
||||
|
||||
if !proto.Equal(userAgent, s.GetUserAgent()) {
|
||||
t.Errorf("user agent =\n%v\nwant\n%v", s.GetUserAgent(), userAgent)
|
||||
if !proto.Equal(want.UserAgent, s.GetUserAgent()) {
|
||||
t.Errorf("user agent =\n%v\nwant\n%v", s.GetUserAgent(), want.UserAgent)
|
||||
}
|
||||
if expirationWindow == 0 {
|
||||
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)
|
||||
return s
|
||||
}
|
||||
|
||||
type wantFactor int
|
||||
@ -129,7 +80,7 @@ const (
|
||||
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 {
|
||||
switch w {
|
||||
case wantUserFactor:
|
||||
@ -194,8 +145,15 @@ func TestServer_CreateSession(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "user agent",
|
||||
name: "full session",
|
||||
req: &session.CreateSessionRequest{
|
||||
Checks: &session.Checks{
|
||||
User: &session.CheckUser{
|
||||
Search: &session.CheckUser_UserId{
|
||||
UserId: User.GetUserId(),
|
||||
},
|
||||
},
|
||||
},
|
||||
Metadata: map[string][]byte{"foo": []byte("bar")},
|
||||
UserAgent: &session.UserAgent{
|
||||
FingerprintId: gu.Ptr("fingerPrintID"),
|
||||
@ -205,6 +163,7 @@ func TestServer_CreateSession(t *testing.T) {
|
||||
"foo": {Values: []string{"foo", "bar"}},
|
||||
},
|
||||
},
|
||||
Lifetime: durationpb.New(5 * time.Minute),
|
||||
},
|
||||
want: &session.CreateSessionResponse{
|
||||
Details: &object.Details{
|
||||
@ -212,14 +171,6 @@ func TestServer_CreateSession(t *testing.T) {
|
||||
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",
|
||||
@ -229,40 +180,6 @@ func TestServer_CreateSession(t *testing.T) {
|
||||
},
|
||||
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",
|
||||
req: &session.CreateSessionRequest{
|
||||
@ -340,8 +257,6 @@ func TestServer_CreateSession(t *testing.T) {
|
||||
}
|
||||
require.NoError(t, err)
|
||||
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)
|
||||
|
||||
ctx := metadata.AppendToOutgoingContext(context.Background(), "Authorization", fmt.Sprintf("Bearer %s", createResp.GetSessionToken()))
|
||||
sessionResp, err := Client.GetSession(ctx, &session.GetSessionRequest{SessionId: createResp.GetSessionId()})
|
||||
require.Error(t, err)
|
||||
require.Nil(t, sessionResp)
|
||||
retryDuration, tick := integration.WaitForAndTickWithMaxDuration(ctx, time.Minute)
|
||||
require.EventuallyWithT(t, func(tt *assert.CollectT) {
|
||||
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) {
|
||||
id, token, _, _ := Instance.CreateVerifiedWebAuthNSession(t, CTX, User.GetUserId())
|
||||
|
||||
ctx := integration.WithAuthorizationToken(context.Background(), token)
|
||||
sessionResp, err := Client.GetSession(ctx, &session.GetSessionRequest{SessionId: id})
|
||||
require.NoError(t, err)
|
||||
|
||||
webAuthN := sessionResp.GetSession().GetFactors().GetWebAuthN()
|
||||
require.NotNil(t, id, webAuthN.GetVerifiedAt().AsTime())
|
||||
require.True(t, webAuthN.GetUserVerified())
|
||||
retryDuration, tick := integration.WaitForAndTickWithMaxDuration(ctx, time.Minute)
|
||||
require.EventuallyWithT(t, func(tt *assert.CollectT) {
|
||||
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) {
|
||||
@ -968,18 +892,30 @@ func Test_ZITADEL_API_session_not_found(t *testing.T) {
|
||||
|
||||
// test session token works
|
||||
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
|
||||
_, err = Client.DeleteSession(CTX, &session.DeleteSessionRequest{
|
||||
_, err := Client.DeleteSession(CTX, &session.DeleteSessionRequest{
|
||||
SessionId: id,
|
||||
SessionToken: gu.Ptr(token),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx = integration.WithAuthorizationToken(context.Background(), token)
|
||||
_, err = Client.GetSession(ctx, &session.GetSessionRequest{SessionId: id})
|
||||
require.Error(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.Error(tt, err) {
|
||||
return
|
||||
}
|
||||
}, retryDuration, tick)
|
||||
}
|
||||
|
||||
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
|
||||
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)
|
||||
|
||||
// ensure session expires and does not work anymore
|
||||
time.Sleep(20 * time.Second)
|
||||
|
262
internal/api/grpc/session/v2/query.go
Normal file
262
internal/api/grpc/session/v2/query.go
Normal 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,
|
||||
}
|
||||
}
|
@ -6,6 +6,7 @@ import (
|
||||
"github.com/zitadel/zitadel/internal/api/authz"
|
||||
"github.com/zitadel/zitadel/internal/api/grpc/server"
|
||||
"github.com/zitadel/zitadel/internal/command"
|
||||
"github.com/zitadel/zitadel/internal/domain"
|
||||
"github.com/zitadel/zitadel/internal/query"
|
||||
"github.com/zitadel/zitadel/pkg/grpc/session/v2"
|
||||
)
|
||||
@ -16,6 +17,8 @@ type Server struct {
|
||||
session.UnimplementedSessionServiceServer
|
||||
command *command.Commands
|
||||
query *query.Queries
|
||||
|
||||
checkPermission domain.PermissionCheck
|
||||
}
|
||||
|
||||
type Config struct{}
|
||||
@ -23,10 +26,12 @@ type Config struct{}
|
||||
func CreateServer(
|
||||
command *command.Commands,
|
||||
query *query.Queries,
|
||||
checkPermission domain.PermissionCheck,
|
||||
) *Server {
|
||||
return &Server{
|
||||
command: command,
|
||||
query: query,
|
||||
command: command,
|
||||
query: query,
|
||||
checkPermission: checkPermission,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -6,56 +6,17 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/muhlemmer/gu"
|
||||
"golang.org/x/text/language"
|
||||
"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/command"
|
||||
"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())
|
||||
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) {
|
||||
checks, metadata, userAgent, lifetime, err := s.createSessionRequestToCommand(ctx, req)
|
||||
if err != nil {
|
||||
@ -110,197 +71,6 @@ func (s *Server) DeleteSession(ctx context.Context, req *session.DeleteSessionRe
|
||||
}, 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) {
|
||||
checks, err := s.checksToCommand(ctx, req.Checks)
|
||||
if err != nil {
|
||||
|
@ -339,9 +339,7 @@ func Test_listSessionsRequestToQuery(t *testing.T) {
|
||||
Limit: 0,
|
||||
Asc: false,
|
||||
},
|
||||
Queries: []query.SearchQuery{
|
||||
mustNewTextQuery(t, query.SessionColumnCreator, "789", query.TextEquals),
|
||||
},
|
||||
Queries: []query.SearchQuery{},
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -359,15 +357,13 @@ func Test_listSessionsRequestToQuery(t *testing.T) {
|
||||
SortingColumn: query.SessionColumnCreationDate,
|
||||
Asc: false,
|
||||
},
|
||||
Queries: []query.SearchQuery{
|
||||
mustNewTextQuery(t, query.SessionColumnCreator, "789", query.TextEquals),
|
||||
},
|
||||
Queries: []query.SearchQuery{},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "with list query and sessions",
|
||||
args: args{
|
||||
ctx: authz.NewMockContext("123", "456", "789"),
|
||||
ctx: authz.SetCtxData(context.Background(), authz.CtxData{AgentID: "agent", UserID: "789"}),
|
||||
req: &session.ListSessionsRequest{
|
||||
Query: &object.ListQuery{
|
||||
Offset: 10,
|
||||
@ -396,6 +392,12 @@ func Test_listSessionsRequestToQuery(t *testing.T) {
|
||||
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),
|
||||
mustNewTimestampQuery(t, query.SessionColumnCreationDate, creationDate, query.TimestampGreater),
|
||||
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
|
||||
}{
|
||||
{
|
||||
name: "creator only",
|
||||
name: "no queries",
|
||||
args: args{
|
||||
ctx: authz.NewMockContext("123", "456", "789"),
|
||||
},
|
||||
want: []query.SearchQuery{
|
||||
mustNewTextQuery(t, query.SessionColumnCreator, "789", query.TextEquals),
|
||||
},
|
||||
want: []query.SearchQuery{},
|
||||
},
|
||||
{
|
||||
name: "invalid argument",
|
||||
@ -491,6 +492,9 @@ func Test_sessionQueriesToQuery(t *testing.T) {
|
||||
Ids: []string{"4", "5", "6"},
|
||||
},
|
||||
}},
|
||||
{Query: &session.SearchQuery_CreatorQuery{
|
||||
CreatorQuery: &session.CreatorQuery{},
|
||||
}},
|
||||
},
|
||||
},
|
||||
want: []query.SearchQuery{
|
||||
@ -511,6 +515,7 @@ func Test_sessionQueriesToQuery(t *testing.T) {
|
||||
|
||||
func Test_sessionQueryToQuery(t *testing.T) {
|
||||
type args struct {
|
||||
ctx context.Context
|
||||
query *session.SearchQuery
|
||||
}
|
||||
tests := []struct {
|
||||
@ -521,60 +526,158 @@ func Test_sessionQueryToQuery(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
name: "invalid argument",
|
||||
args: args{&session.SearchQuery{
|
||||
Query: nil,
|
||||
}},
|
||||
args: args{
|
||||
context.Background(),
|
||||
&session.SearchQuery{
|
||||
Query: nil,
|
||||
}},
|
||||
wantErr: zerrors.ThrowInvalidArgument(nil, "GRPC-Sfefs", "List.Query.Invalid"),
|
||||
},
|
||||
{
|
||||
name: "ids query",
|
||||
args: args{&session.SearchQuery{
|
||||
Query: &session.SearchQuery_IdsQuery{
|
||||
IdsQuery: &session.IDsQuery{
|
||||
Ids: []string{"1", "2", "3"},
|
||||
args: args{
|
||||
context.Background(),
|
||||
&session.SearchQuery{
|
||||
Query: &session.SearchQuery_IdsQuery{
|
||||
IdsQuery: &session.IDsQuery{
|
||||
Ids: []string{"1", "2", "3"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}},
|
||||
}},
|
||||
want: mustNewListQuery(t, query.SessionColumnID, []interface{}{"1", "2", "3"}, query.ListIn),
|
||||
},
|
||||
{
|
||||
name: "user id query",
|
||||
args: args{&session.SearchQuery{
|
||||
Query: &session.SearchQuery_UserIdQuery{
|
||||
UserIdQuery: &session.UserIDQuery{
|
||||
Id: "10",
|
||||
args: args{
|
||||
context.Background(),
|
||||
&session.SearchQuery{
|
||||
Query: &session.SearchQuery_UserIdQuery{
|
||||
UserIdQuery: &session.UserIDQuery{
|
||||
Id: "10",
|
||||
},
|
||||
},
|
||||
},
|
||||
}},
|
||||
}},
|
||||
want: mustNewTextQuery(t, query.SessionColumnUserID, "10", query.TextEquals),
|
||||
},
|
||||
{
|
||||
name: "creation date query",
|
||||
args: args{&session.SearchQuery{
|
||||
Query: &session.SearchQuery_CreationDateQuery{
|
||||
CreationDateQuery: &session.CreationDateQuery{
|
||||
CreationDate: timestamppb.New(creationDate),
|
||||
Method: objpb.TimestampQueryMethod_TIMESTAMP_QUERY_METHOD_LESS,
|
||||
args: args{
|
||||
context.Background(),
|
||||
&session.SearchQuery{
|
||||
Query: &session.SearchQuery_CreationDateQuery{
|
||||
CreationDateQuery: &session.CreationDateQuery{
|
||||
CreationDate: timestamppb.New(creationDate),
|
||||
Method: objpb.TimestampQueryMethod_TIMESTAMP_QUERY_METHOD_LESS,
|
||||
},
|
||||
},
|
||||
},
|
||||
}},
|
||||
}},
|
||||
want: mustNewTimestampQuery(t, query.SessionColumnCreationDate, creationDate, query.TimestampLess),
|
||||
},
|
||||
{
|
||||
name: "creation date query with default method",
|
||||
args: args{&session.SearchQuery{
|
||||
Query: &session.SearchQuery_CreationDateQuery{
|
||||
CreationDateQuery: &session.CreationDateQuery{
|
||||
CreationDate: timestamppb.New(creationDate),
|
||||
args: args{
|
||||
context.Background(),
|
||||
&session.SearchQuery{
|
||||
Query: &session.SearchQuery_CreationDateQuery{
|
||||
CreationDateQuery: &session.CreationDateQuery{
|
||||
CreationDate: timestamppb.New(creationDate),
|
||||
},
|
||||
},
|
||||
},
|
||||
}},
|
||||
}},
|
||||
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 {
|
||||
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)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
|
512
internal/api/grpc/session/v2beta/integration_test/query_test.go
Normal file
512
internal/api/grpc/session/v2beta/integration_test/query_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
@ -5,7 +5,6 @@ package session_test
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@ -14,7 +13,6 @@ import (
|
||||
"github.com/pquerna/otp/totp"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/zitadel/logging"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/metadata"
|
||||
"google.golang.org/grpc/status"
|
||||
@ -29,62 +27,6 @@ import (
|
||||
"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 {
|
||||
t.Helper()
|
||||
require.NotEmpty(t, id)
|
||||
@ -96,15 +38,25 @@ func verifyCurrentSession(t testing.TB, id, token string, sequence uint64, windo
|
||||
})
|
||||
require.NoError(t, err)
|
||||
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.GetChangeDate().AsTime(), time.Now().Add(-window), time.Now().Add(window))
|
||||
assert.Equal(t, sequence, s.GetSequence())
|
||||
assert.Equal(t, metadata, s.GetMetadata())
|
||||
assert.Equal(t, want.Sequence, s.GetSequence())
|
||||
assert.Equal(t, want.Metadata, s.GetMetadata())
|
||||
|
||||
if !proto.Equal(userAgent, s.GetUserAgent()) {
|
||||
t.Errorf("user agent =\n%v\nwant\n%v", s.GetUserAgent(), userAgent)
|
||||
if !proto.Equal(want.UserAgent, s.GetUserAgent()) {
|
||||
t.Errorf("user agent =\n%v\nwant\n%v", s.GetUserAgent(), want.UserAgent)
|
||||
}
|
||||
if expirationWindow == 0 {
|
||||
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)
|
||||
return s
|
||||
}
|
||||
|
||||
type wantFactor int
|
||||
@ -129,7 +80,7 @@ const (
|
||||
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 {
|
||||
switch w {
|
||||
case wantUserFactor:
|
||||
@ -194,8 +145,15 @@ func TestServer_CreateSession(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "user agent",
|
||||
name: "full session",
|
||||
req: &session.CreateSessionRequest{
|
||||
Checks: &session.Checks{
|
||||
User: &session.CheckUser{
|
||||
Search: &session.CheckUser_UserId{
|
||||
UserId: User.GetUserId(),
|
||||
},
|
||||
},
|
||||
},
|
||||
Metadata: map[string][]byte{"foo": []byte("bar")},
|
||||
UserAgent: &session.UserAgent{
|
||||
FingerprintId: gu.Ptr("fingerPrintID"),
|
||||
@ -205,6 +163,7 @@ func TestServer_CreateSession(t *testing.T) {
|
||||
"foo": {Values: []string{"foo", "bar"}},
|
||||
},
|
||||
},
|
||||
Lifetime: durationpb.New(5 * time.Minute),
|
||||
},
|
||||
want: &session.CreateSessionResponse{
|
||||
Details: &object.Details{
|
||||
@ -212,14 +171,6 @@ func TestServer_CreateSession(t *testing.T) {
|
||||
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",
|
||||
@ -229,40 +180,6 @@ func TestServer_CreateSession(t *testing.T) {
|
||||
},
|
||||
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",
|
||||
req: &session.CreateSessionRequest{
|
||||
@ -340,8 +257,6 @@ func TestServer_CreateSession(t *testing.T) {
|
||||
}
|
||||
require.NoError(t, err)
|
||||
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)
|
||||
|
||||
ctx := metadata.AppendToOutgoingContext(context.Background(), "Authorization", fmt.Sprintf("Bearer %s", createResp.GetSessionToken()))
|
||||
sessionResp, err := Client.GetSession(ctx, &session.GetSessionRequest{SessionId: createResp.GetSessionId()})
|
||||
require.Error(t, err)
|
||||
require.Nil(t, sessionResp)
|
||||
retryDuration, tick := integration.WaitForAndTickWithMaxDuration(ctx, time.Minute)
|
||||
require.EventuallyWithT(t, func(tt *assert.CollectT) {
|
||||
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) {
|
||||
id, token, _, _ := Instance.CreateVerifiedWebAuthNSession(t, CTX, User.GetUserId())
|
||||
|
||||
ctx := integration.WithAuthorizationToken(context.Background(), token)
|
||||
sessionResp, err := Client.GetSession(ctx, &session.GetSessionRequest{SessionId: id})
|
||||
require.NoError(t, err)
|
||||
|
||||
webAuthN := sessionResp.GetSession().GetFactors().GetWebAuthN()
|
||||
require.NotNil(t, id, webAuthN.GetVerifiedAt().AsTime())
|
||||
require.True(t, webAuthN.GetUserVerified())
|
||||
retryDuration, tick := integration.WaitForAndTickWithMaxDuration(ctx, time.Minute)
|
||||
require.EventuallyWithT(t, func(tt *assert.CollectT) {
|
||||
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) {
|
||||
@ -968,18 +892,30 @@ func Test_ZITADEL_API_session_not_found(t *testing.T) {
|
||||
|
||||
// test session token works
|
||||
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
|
||||
_, err = Client.DeleteSession(CTX, &session.DeleteSessionRequest{
|
||||
_, err := Client.DeleteSession(CTX, &session.DeleteSessionRequest{
|
||||
SessionId: id,
|
||||
SessionToken: gu.Ptr(token),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx = integration.WithAuthorizationToken(context.Background(), token)
|
||||
_, err = Client.GetSession(ctx, &session.GetSessionRequest{SessionId: id})
|
||||
require.Error(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.Error(tt, err) {
|
||||
return
|
||||
}
|
||||
}, retryDuration, tick)
|
||||
}
|
||||
|
||||
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
|
||||
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)
|
||||
|
||||
// ensure session expires and does not work anymore
|
||||
time.Sleep(20 * time.Second)
|
||||
|
@ -6,6 +6,7 @@ import (
|
||||
"github.com/zitadel/zitadel/internal/api/authz"
|
||||
"github.com/zitadel/zitadel/internal/api/grpc/server"
|
||||
"github.com/zitadel/zitadel/internal/command"
|
||||
"github.com/zitadel/zitadel/internal/domain"
|
||||
"github.com/zitadel/zitadel/internal/query"
|
||||
session "github.com/zitadel/zitadel/pkg/grpc/session/v2beta"
|
||||
)
|
||||
@ -16,6 +17,8 @@ type Server struct {
|
||||
session.UnimplementedSessionServiceServer
|
||||
command *command.Commands
|
||||
query *query.Queries
|
||||
|
||||
checkPermission domain.PermissionCheck
|
||||
}
|
||||
|
||||
type Config struct{}
|
||||
@ -23,10 +26,12 @@ type Config struct{}
|
||||
func CreateServer(
|
||||
command *command.Commands,
|
||||
query *query.Queries,
|
||||
checkPermission domain.PermissionCheck,
|
||||
) *Server {
|
||||
return &Server{
|
||||
command: command,
|
||||
query: query,
|
||||
command: command,
|
||||
query: query,
|
||||
checkPermission: checkPermission,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -32,7 +32,7 @@ var (
|
||||
)
|
||||
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
@ -46,7 +46,7 @@ func (s *Server) ListSessions(ctx context.Context, req *session.ListSessionsRequ
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sessions, err := s.query.SearchSessions(ctx, queries)
|
||||
sessions, err := s.query.SearchSessions(ctx, queries, s.checkPermission)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -10,12 +10,11 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/zitadel/logging"
|
||||
|
||||
"github.com/brianvoe/gofakeit/v6"
|
||||
"github.com/muhlemmer/gu"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/zitadel/logging"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
|
@ -5,6 +5,8 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
const (
|
||||
@ -14,6 +16,7 @@ const (
|
||||
CacheControl = "cache-control"
|
||||
ContentType = "content-type"
|
||||
ContentLength = "content-length"
|
||||
ContentLocation = "content-location"
|
||||
Expires = "expires"
|
||||
Location = "location"
|
||||
Origin = "origin"
|
||||
@ -42,6 +45,9 @@ const (
|
||||
PermissionsPolicy = "permissions-policy"
|
||||
|
||||
ZitadelOrgID = "x-zitadel-orgid"
|
||||
|
||||
OrgIdInPathVariableName = "orgId"
|
||||
OrgIdInPathVariable = "{" + OrgIdInPathVariableName + "}"
|
||||
)
|
||||
|
||||
type key int
|
||||
@ -104,6 +110,12 @@ func GetAuthorization(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)
|
||||
}
|
||||
|
||||
|
@ -2,12 +2,15 @@ package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/api/authz"
|
||||
http_util "github.com/zitadel/zitadel/internal/api/http"
|
||||
"github.com/zitadel/zitadel/internal/telemetry/tracing"
|
||||
"github.com/zitadel/zitadel/internal/zerrors"
|
||||
)
|
||||
|
||||
type AuthInterceptor struct {
|
||||
@ -23,34 +26,40 @@ func AuthorizationInterceptor(verifier authz.APITokenVerifier, authConfig authz.
|
||||
}
|
||||
|
||||
func (a *AuthInterceptor) Handler(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
})
|
||||
return a.HandlerFunc(next)
|
||||
}
|
||||
|
||||
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) {
|
||||
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) 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{}
|
||||
|
||||
func authorize(r *http.Request, verifier authz.APITokenVerifier, authConfig authz.Config) (_ context.Context, err error) {
|
||||
ctx := r.Context()
|
||||
authOpt, needsToken := verifier.CheckAuthMethod(r.Method + ":" + r.RequestURI)
|
||||
|
||||
authOpt, needsToken := checkAuthMethod(r, verifier)
|
||||
if !needsToken {
|
||||
return ctx, nil
|
||||
}
|
||||
@ -59,7 +68,7 @@ func authorize(r *http.Request, verifier authz.APITokenVerifier, authConfig auth
|
||||
|
||||
authToken := http_util.GetAuthorization(r)
|
||||
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)
|
||||
@ -69,3 +78,30 @@ func authorize(r *http.Request, verifier authz.APITokenVerifier, authConfig auth
|
||||
span.End()
|
||||
return ctxSetter(ctx), nil
|
||||
}
|
||||
|
||||
func checkAuthMethod(r *http.Request, verifier authz.APITokenVerifier) (authz.Option, bool) {
|
||||
authOpt, needsToken := verifier.CheckAuthMethod(r.Method + ":" + r.RequestURI)
|
||||
if needsToken {
|
||||
return authOpt, true
|
||||
}
|
||||
|
||||
route := mux.CurrentRoute(r)
|
||||
if route == nil {
|
||||
return authOpt, false
|
||||
}
|
||||
|
||||
pathTemplate, err := route.GetPathTemplate()
|
||||
if err != nil || pathTemplate == "" {
|
||||
return authOpt, false
|
||||
}
|
||||
|
||||
// the path prefix is usually handled in a router in upper layer
|
||||
// trim the query and the path of the url to get the correct path prefix
|
||||
pathPrefix := r.RequestURI
|
||||
if i := strings.Index(pathPrefix, "?"); i != -1 {
|
||||
pathPrefix = pathPrefix[0:i]
|
||||
}
|
||||
pathPrefix = strings.TrimSuffix(pathPrefix, r.URL.Path)
|
||||
|
||||
return verifier.CheckAuthMethod(r.Method + ":" + pathPrefix + pathTemplate)
|
||||
}
|
||||
|
26
internal/api/http/middleware/handler.go
Normal file
26
internal/api/http/middleware/handler.go
Normal file
@ -0,0 +1,26 @@
|
||||
package middleware
|
||||
|
||||
import "net/http"
|
||||
|
||||
// HandlerFuncWithError is a http handler func which can return an error
|
||||
// the error should then get handled later on in the pipeline by an error handler
|
||||
// the error handler can be dependent on the interface standard (e.g. SCIM, Problem Details, ...)
|
||||
type HandlerFuncWithError = func(w http.ResponseWriter, r *http.Request) error
|
||||
|
||||
// MiddlewareWithErrorFunc is a http middleware which can return an error
|
||||
// the error should then get handled later on in the pipeline by an error handler
|
||||
// the error handler can be dependent on the interface standard (e.g. SCIM, Problem Details, ...)
|
||||
type MiddlewareWithErrorFunc = func(HandlerFuncWithError) HandlerFuncWithError
|
||||
|
||||
// ErrorHandlerFunc handles errors and returns a regular http handler
|
||||
type ErrorHandlerFunc = func(HandlerFuncWithError) http.Handler
|
||||
|
||||
func ChainedWithErrorHandler(errorHandler ErrorHandlerFunc, middlewares ...MiddlewareWithErrorFunc) func(HandlerFuncWithError) http.Handler {
|
||||
return func(next HandlerFuncWithError) http.Handler {
|
||||
for i := len(middlewares) - 1; i >= 0; i-- {
|
||||
next = middlewares[i](next)
|
||||
}
|
||||
|
||||
return errorHandler(next)
|
||||
}
|
||||
}
|
@ -34,43 +34,57 @@ func InstanceInterceptor(verifier authz.InstanceVerifier, externalDomain string,
|
||||
}
|
||||
|
||||
func (a *instanceInterceptor) Handler(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
a.handleInstance(w, r, next)
|
||||
})
|
||||
return a.HandlerFunc(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) {
|
||||
a.handleInstance(w, r, next)
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
ctx, err := a.setInstanceIfNeeded(r.Context(), r)
|
||||
if err == nil {
|
||||
r = r.WithContext(ctx)
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
ctx, err := setInstance(r, a.verifier)
|
||||
if err != nil {
|
||||
|
||||
origin := zitadel_http.DomainContext(r.Context())
|
||||
logging.WithFields("origin", origin.Origin(), "externalDomain", a.externalDomain).WithError(err).Error("unable to set instance")
|
||||
|
||||
zErr := new(zerrors.ZitadelError)
|
||||
if errors.As(err, &zErr) {
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
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) {
|
||||
ctx := r.Context()
|
||||
func (a *instanceInterceptor) HandlerFuncWithError(next HandlerFuncWithError) HandlerFuncWithError {
|
||||
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)
|
||||
defer func() { span.EndWithError(err) }()
|
||||
|
||||
|
@ -72,7 +72,7 @@ func Test_instanceInterceptor_Handler(t *testing.T) {
|
||||
translator: newZitadelTranslator(),
|
||||
}
|
||||
next := &testHandler{}
|
||||
got := a.HandlerFunc(next.ServeHTTP)
|
||||
got := a.HandlerFunc(next)
|
||||
rr := httptest.NewRecorder()
|
||||
got.ServeHTTP(rr, tt.args.request)
|
||||
assert.Equal(t, tt.res.statusCode, rr.Code)
|
||||
@ -136,7 +136,7 @@ func Test_instanceInterceptor_HandlerFunc(t *testing.T) {
|
||||
translator: newZitadelTranslator(),
|
||||
}
|
||||
next := &testHandler{}
|
||||
got := a.HandlerFunc(next.ServeHTTP)
|
||||
got := a.HandlerFunc(next)
|
||||
rr := httptest.NewRecorder()
|
||||
got.ServeHTTP(rr, tt.args.request)
|
||||
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) {
|
||||
type args struct {
|
||||
r *http.Request
|
||||
ctx context.Context
|
||||
verifier authz.InstanceVerifier
|
||||
}
|
||||
type res struct {
|
||||
@ -162,10 +231,7 @@ func Test_setInstance(t *testing.T) {
|
||||
{
|
||||
"no domain context, not found error",
|
||||
args{
|
||||
r: func() *http.Request {
|
||||
r := httptest.NewRequest("", "/url", nil)
|
||||
return r
|
||||
}(),
|
||||
ctx: context.Background(),
|
||||
verifier: &mockInstanceVerifier{},
|
||||
},
|
||||
res{
|
||||
@ -176,10 +242,7 @@ func Test_setInstance(t *testing.T) {
|
||||
{
|
||||
"instanceHost found, ok",
|
||||
args{
|
||||
r: func() *http.Request {
|
||||
r := httptest.NewRequest("", "/url", nil)
|
||||
return r.WithContext(zitadel_http.WithDomainContext(r.Context(), &zitadel_http.DomainCtx{InstanceHost: "host", Protocol: "https"}))
|
||||
}(),
|
||||
ctx: zitadel_http.WithDomainContext(context.Background(), &zitadel_http.DomainCtx{InstanceHost: "host", Protocol: "https"}),
|
||||
verifier: &mockInstanceVerifier{instanceHost: "host"},
|
||||
},
|
||||
res{
|
||||
@ -190,10 +253,7 @@ func Test_setInstance(t *testing.T) {
|
||||
{
|
||||
"instanceHost not found, error",
|
||||
args{
|
||||
r: func() *http.Request {
|
||||
r := httptest.NewRequest("", "/url", nil)
|
||||
return r.WithContext(zitadel_http.WithDomainContext(r.Context(), &zitadel_http.DomainCtx{InstanceHost: "fromorigin:9999", Protocol: "https"}))
|
||||
}(),
|
||||
ctx: zitadel_http.WithDomainContext(context.Background(), &zitadel_http.DomainCtx{InstanceHost: "fromorigin:9999", Protocol: "https"}),
|
||||
verifier: &mockInstanceVerifier{instanceHost: "unknowndomain"},
|
||||
},
|
||||
res{
|
||||
@ -204,7 +264,7 @@ func Test_setInstance(t *testing.T) {
|
||||
}
|
||||
for _, tt := range tests {
|
||||
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 {
|
||||
t.Errorf("setInstance() error = %v, wantErr %v", err, tt.res.err)
|
||||
return
|
||||
|
22
internal/api/scim/authz.go
Normal file
22
internal/api/scim/authz.go
Normal 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,
|
||||
},
|
||||
}
|
6
internal/api/scim/config/config.go
Normal file
6
internal/api/scim/config/config.go
Normal file
@ -0,0 +1,6 @@
|
||||
package config
|
||||
|
||||
type Config struct {
|
||||
EmailVerified bool
|
||||
PhoneVerified bool
|
||||
}
|
29
internal/api/scim/integration_test/scim_test.go
Normal file
29
internal/api/scim/integration_test/scim_test.go
Normal 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()
|
||||
}())
|
||||
}
|
116
internal/api/scim/integration_test/testdata/users_create_test_full.json
vendored
Normal file
116
internal/api/scim/integration_test/testdata/users_create_test_full.json
vendored
Normal file
@ -0,0 +1,116 @@
|
||||
{
|
||||
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
|
||||
"externalId": "701984",
|
||||
"userName": "bjensen@example.com",
|
||||
"name": {
|
||||
"formatted": "Ms. Barbara J Jensen, III",
|
||||
"familyName": "Jensen",
|
||||
"givenName": "Barbara",
|
||||
"middleName": "Jane",
|
||||
"honorificPrefix": "Ms.",
|
||||
"honorificSuffix": "III"
|
||||
},
|
||||
"displayName": "Babs Jensen",
|
||||
"nickName": "Babs",
|
||||
"profileUrl": "http://login.example.com/bjensen",
|
||||
"emails": [
|
||||
{
|
||||
"value": "bjensen@example.com",
|
||||
"type": "work",
|
||||
"primary": true
|
||||
},
|
||||
{
|
||||
"value": "babs@jensen.org",
|
||||
"type": "home"
|
||||
}
|
||||
],
|
||||
"addresses": [
|
||||
{
|
||||
"type": "work",
|
||||
"streetAddress": "100 Universal City Plaza",
|
||||
"locality": "Hollywood",
|
||||
"region": "CA",
|
||||
"postalCode": "91608",
|
||||
"country": "USA",
|
||||
"formatted": "100 Universal City Plaza\nHollywood, CA 91608 USA",
|
||||
"primary": true
|
||||
},
|
||||
{
|
||||
"type": "home",
|
||||
"streetAddress": "456 Hollywood Blvd",
|
||||
"locality": "Hollywood",
|
||||
"region": "CA",
|
||||
"postalCode": "91608",
|
||||
"country": "USA",
|
||||
"formatted": "456 Hollywood Blvd\nHollywood, CA 91608 USA"
|
||||
}
|
||||
],
|
||||
"phoneNumbers": [
|
||||
{
|
||||
"value": "555-555-5555",
|
||||
"type": "work",
|
||||
"primary": true
|
||||
},
|
||||
{
|
||||
"value": "555-555-4444",
|
||||
"type": "mobile"
|
||||
}
|
||||
],
|
||||
"ims": [
|
||||
{
|
||||
"value": "someaimhandle",
|
||||
"type": "aim"
|
||||
},
|
||||
{
|
||||
"value": "twitterhandle",
|
||||
"type": "X"
|
||||
}
|
||||
],
|
||||
"photos": [
|
||||
{
|
||||
"value":
|
||||
"https://photos.example.com/profilephoto/72930000000Ccne/F",
|
||||
"type": "photo"
|
||||
},
|
||||
{
|
||||
"value":
|
||||
"https://photos.example.com/profilephoto/72930000000Ccne/T",
|
||||
"type": "thumbnail"
|
||||
}
|
||||
],
|
||||
"roles": [
|
||||
{
|
||||
"value": "my-role-1",
|
||||
"display": "Rolle 1",
|
||||
"type": "main-role",
|
||||
"primary": true
|
||||
},
|
||||
{
|
||||
"value": "my-role-2",
|
||||
"display": "Rolle 2",
|
||||
"type": "secondary-role",
|
||||
"primary": false
|
||||
}
|
||||
],
|
||||
"entitlements": [
|
||||
{
|
||||
"value": "my-entitlement-1",
|
||||
"display": "Entitlement 1",
|
||||
"type": "main-entitlement",
|
||||
"primary": true
|
||||
},
|
||||
{
|
||||
"value": "my-entitlement-2",
|
||||
"display": "Entitlement 2",
|
||||
"type": "secondary-entitlement",
|
||||
"primary": false
|
||||
}
|
||||
],
|
||||
"userType": "Employee",
|
||||
"title": "Tour Guide",
|
||||
"preferredLanguage": "en-US",
|
||||
"locale": "en-US",
|
||||
"timezone": "America/Los_Angeles",
|
||||
"active":true,
|
||||
"password": "Password1!"
|
||||
}
|
17
internal/api/scim/integration_test/testdata/users_create_test_invalid_locale.json
vendored
Normal file
17
internal/api/scim/integration_test/testdata/users_create_test_invalid_locale.json
vendored
Normal file
@ -0,0 +1,17 @@
|
||||
{
|
||||
"schemas": [
|
||||
"urn:ietf:params:scim:schemas:core:2.0:User"
|
||||
],
|
||||
"userName": "acmeUser1",
|
||||
"name": {
|
||||
"familyName": "Ross",
|
||||
"givenName": "Bethany"
|
||||
},
|
||||
"emails": [
|
||||
{
|
||||
"value": "user1@example.com",
|
||||
"primary": true
|
||||
}
|
||||
],
|
||||
"locale": "fooBar"
|
||||
}
|
17
internal/api/scim/integration_test/testdata/users_create_test_invalid_password.json
vendored
Normal file
17
internal/api/scim/integration_test/testdata/users_create_test_invalid_password.json
vendored
Normal file
@ -0,0 +1,17 @@
|
||||
{
|
||||
"schemas": [
|
||||
"urn:ietf:params:scim:schemas:core:2.0:User"
|
||||
],
|
||||
"userName": "acmeUser1",
|
||||
"name": {
|
||||
"familyName": "Ross",
|
||||
"givenName": "Bethany"
|
||||
},
|
||||
"emails": [
|
||||
{
|
||||
"value": "user1@example.com",
|
||||
"primary": true
|
||||
}
|
||||
],
|
||||
"password": "fooBar"
|
||||
}
|
17
internal/api/scim/integration_test/testdata/users_create_test_invalid_profile_url.json
vendored
Normal file
17
internal/api/scim/integration_test/testdata/users_create_test_invalid_profile_url.json
vendored
Normal file
@ -0,0 +1,17 @@
|
||||
{
|
||||
"schemas": [
|
||||
"urn:ietf:params:scim:schemas:core:2.0:User"
|
||||
],
|
||||
"userName": "acmeUser1",
|
||||
"name": {
|
||||
"familyName": "Ross",
|
||||
"givenName": "Bethany"
|
||||
},
|
||||
"emails": [
|
||||
{
|
||||
"value": "user1@example.com",
|
||||
"primary": true
|
||||
}
|
||||
],
|
||||
"profileUrl": "ftp://login.example.com/bjensen"
|
||||
}
|
17
internal/api/scim/integration_test/testdata/users_create_test_invalid_timezone.json
vendored
Normal file
17
internal/api/scim/integration_test/testdata/users_create_test_invalid_timezone.json
vendored
Normal file
@ -0,0 +1,17 @@
|
||||
{
|
||||
"schemas": [
|
||||
"urn:ietf:params:scim:schemas:core:2.0:User"
|
||||
],
|
||||
"userName": "acmeUser1",
|
||||
"name": {
|
||||
"familyName": "Ross",
|
||||
"givenName": "Bethany"
|
||||
},
|
||||
"emails": [
|
||||
{
|
||||
"value": "user1@example.com",
|
||||
"primary": true
|
||||
}
|
||||
],
|
||||
"timezone": "fooBar"
|
||||
}
|
16
internal/api/scim/integration_test/testdata/users_create_test_minimal.json
vendored
Normal file
16
internal/api/scim/integration_test/testdata/users_create_test_minimal.json
vendored
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"schemas": [
|
||||
"urn:ietf:params:scim:schemas:core:2.0:User"
|
||||
],
|
||||
"userName": "acmeUser1",
|
||||
"name": {
|
||||
"familyName": "Ross",
|
||||
"givenName": "Bethany"
|
||||
},
|
||||
"emails": [
|
||||
{
|
||||
"value": "user1@example.com",
|
||||
"primary": true
|
||||
}
|
||||
]
|
||||
}
|
17
internal/api/scim/integration_test/testdata/users_create_test_minimal_inactive.json
vendored
Normal file
17
internal/api/scim/integration_test/testdata/users_create_test_minimal_inactive.json
vendored
Normal file
@ -0,0 +1,17 @@
|
||||
{
|
||||
"schemas": [
|
||||
"urn:ietf:params:scim:schemas:core:2.0:User"
|
||||
],
|
||||
"userName": "acmeUser1",
|
||||
"name": {
|
||||
"familyName": "Ross",
|
||||
"givenName": "Bethany"
|
||||
},
|
||||
"emails": [
|
||||
{
|
||||
"value": "user1@example.com",
|
||||
"primary": true
|
||||
}
|
||||
],
|
||||
"active": false
|
||||
}
|
10
internal/api/scim/integration_test/testdata/users_create_test_missing_email.json
vendored
Normal file
10
internal/api/scim/integration_test/testdata/users_create_test_missing_email.json
vendored
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"schemas": [
|
||||
"urn:ietf:params:scim:schemas:core:2.0:User"
|
||||
],
|
||||
"userName": "acmeUser1",
|
||||
"name": {
|
||||
"familyName": "Ross",
|
||||
"givenName": "Bethany"
|
||||
}
|
||||
}
|
15
internal/api/scim/integration_test/testdata/users_create_test_missing_name.json
vendored
Normal file
15
internal/api/scim/integration_test/testdata/users_create_test_missing_name.json
vendored
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"schemas": [
|
||||
"urn:ietf:params:scim:schemas:core:2.0:User"
|
||||
],
|
||||
"userName": "acmeUser1",
|
||||
"name": {
|
||||
"givenName": "Bethany"
|
||||
},
|
||||
"emails": [
|
||||
{
|
||||
"value": "user1@example.com",
|
||||
"primary": true
|
||||
}
|
||||
]
|
||||
}
|
15
internal/api/scim/integration_test/testdata/users_create_test_missing_username.json
vendored
Normal file
15
internal/api/scim/integration_test/testdata/users_create_test_missing_username.json
vendored
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"schemas": [
|
||||
"urn:ietf:params:scim:schemas:core:2.0:User"
|
||||
],
|
||||
"name": {
|
||||
"familyName": "Ross",
|
||||
"givenName": "Bethany"
|
||||
},
|
||||
"emails": [
|
||||
{
|
||||
"value": "user1@example.com",
|
||||
"primary": true
|
||||
}
|
||||
]
|
||||
}
|
116
internal/api/scim/integration_test/testdata/users_replace_test_full.json
vendored
Normal file
116
internal/api/scim/integration_test/testdata/users_replace_test_full.json
vendored
Normal 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"
|
||||
}
|
16
internal/api/scim/integration_test/testdata/users_replace_test_minimal.json
vendored
Normal file
16
internal/api/scim/integration_test/testdata/users_replace_test_minimal.json
vendored
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
17
internal/api/scim/integration_test/testdata/users_replace_test_minimal_with_external_id.json
vendored
Normal file
17
internal/api/scim/integration_test/testdata/users_replace_test_minimal_with_external_id.json
vendored
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
405
internal/api/scim/integration_test/users_create_test.go
Normal file
405
internal/api/scim/integration_test/users_create_test.go
Normal 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)
|
||||
}
|
90
internal/api/scim/integration_test/users_delete_test.go
Normal file
90
internal/api/scim/integration_test/users_delete_test.go
Normal 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)
|
||||
}
|
276
internal/api/scim/integration_test/users_get_test.go
Normal file
276
internal/api/scim/integration_test/users_get_test.go
Normal 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)
|
||||
}
|
331
internal/api/scim/integration_test/users_replace_test.go
Normal file
331
internal/api/scim/integration_test/users_replace_test.go
Normal 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)
|
||||
}
|
23
internal/api/scim/metadata/context.go
Normal file
23
internal/api/scim/metadata/context.go
Normal file
@ -0,0 +1,23 @@
|
||||
package metadata
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
type provisioningDomainKeyType struct{}
|
||||
|
||||
var provisioningDomainKey provisioningDomainKeyType
|
||||
|
||||
type ScimContextData struct {
|
||||
ProvisioningDomain string
|
||||
ExternalIDScopedMetadataKey ScopedKey
|
||||
}
|
||||
|
||||
func SetScimContextData(ctx context.Context, data ScimContextData) context.Context {
|
||||
return context.WithValue(ctx, provisioningDomainKey, data)
|
||||
}
|
||||
|
||||
func GetScimContextData(ctx context.Context) ScimContextData {
|
||||
data, _ := ctx.Value(provisioningDomainKey).(ScimContextData)
|
||||
return data
|
||||
}
|
60
internal/api/scim/metadata/metadata.go
Normal file
60
internal/api/scim/metadata/metadata.go
Normal file
@ -0,0 +1,60 @@
|
||||
package metadata
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Key string
|
||||
type ScopedKey string
|
||||
|
||||
const (
|
||||
externalIdProvisioningDomainPlaceholder = "{provisioningDomain}"
|
||||
|
||||
KeyPrefix = "urn:zitadel:scim:"
|
||||
KeyProvisioningDomain Key = KeyPrefix + "provisioning_domain"
|
||||
|
||||
KeyExternalId Key = KeyPrefix + "externalId"
|
||||
keyScopedExternalIdTemplate = KeyPrefix + externalIdProvisioningDomainPlaceholder + ":externalId"
|
||||
KeyMiddleName Key = KeyPrefix + "name.middleName"
|
||||
KeyHonorificPrefix Key = KeyPrefix + "name.honorificPrefix"
|
||||
KeyHonorificSuffix Key = KeyPrefix + "name.honorificSuffix"
|
||||
KeyProfileUrl Key = KeyPrefix + "profileURL"
|
||||
KeyTitle Key = KeyPrefix + "title"
|
||||
KeyLocale Key = KeyPrefix + "locale"
|
||||
KeyTimezone Key = KeyPrefix + "timezone"
|
||||
KeyIms Key = KeyPrefix + "ims"
|
||||
KeyPhotos Key = KeyPrefix + "photos"
|
||||
KeyAddresses Key = KeyPrefix + "addresses"
|
||||
KeyEntitlements Key = KeyPrefix + "entitlements"
|
||||
KeyRoles Key = KeyPrefix + "roles"
|
||||
)
|
||||
|
||||
var ScimUserRelevantMetadataKeys = []Key{
|
||||
KeyExternalId,
|
||||
KeyMiddleName,
|
||||
KeyHonorificPrefix,
|
||||
KeyHonorificSuffix,
|
||||
KeyProfileUrl,
|
||||
KeyTitle,
|
||||
KeyLocale,
|
||||
KeyTimezone,
|
||||
KeyIms,
|
||||
KeyPhotos,
|
||||
KeyAddresses,
|
||||
KeyEntitlements,
|
||||
KeyRoles,
|
||||
}
|
||||
|
||||
func ScopeExternalIdKey(provisioningDomain string) ScopedKey {
|
||||
return ScopedKey(strings.Replace(keyScopedExternalIdTemplate, externalIdProvisioningDomainPlaceholder, provisioningDomain, 1))
|
||||
}
|
||||
|
||||
func ScopeKey(ctx context.Context, key Key) ScopedKey {
|
||||
// only the externalID is scoped
|
||||
if key == KeyExternalId {
|
||||
return GetScimContextData(ctx).ExternalIDScopedMetadataKey
|
||||
}
|
||||
|
||||
return ScopedKey(key)
|
||||
}
|
53
internal/api/scim/middleware/content_type_middleware.go
Normal file
53
internal/api/scim/middleware/content_type_middleware.go
Normal file
@ -0,0 +1,53 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"mime"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/zitadel/logging"
|
||||
|
||||
zhttp "github.com/zitadel/zitadel/internal/api/http"
|
||||
"github.com/zitadel/zitadel/internal/api/http/middleware"
|
||||
"github.com/zitadel/zitadel/internal/zerrors"
|
||||
)
|
||||
|
||||
const (
|
||||
ContentTypeScim = "application/scim+json"
|
||||
ContentTypeJson = "application/json"
|
||||
)
|
||||
|
||||
func ContentTypeMiddleware(next middleware.HandlerFuncWithError) middleware.HandlerFuncWithError {
|
||||
return func(w http.ResponseWriter, r *http.Request) error {
|
||||
w.Header().Set(zhttp.ContentType, ContentTypeScim)
|
||||
|
||||
if !validateContentType(r.Header.Get(zhttp.ContentType)) {
|
||||
return zerrors.ThrowInvalidArgumentf(nil, "SMCM-12x4", "Invalid content type header")
|
||||
}
|
||||
|
||||
if !validateContentType(r.Header.Get(zhttp.Accept)) {
|
||||
return zerrors.ThrowInvalidArgumentf(nil, "SMCM-12x5", "Invalid accept header")
|
||||
}
|
||||
|
||||
return next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func validateContentType(contentType string) bool {
|
||||
if contentType == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
mediaType, params, err := mime.ParseMediaType(contentType)
|
||||
if err != nil {
|
||||
logging.OnError(err).Warn("failed to parse content type header")
|
||||
return false
|
||||
}
|
||||
|
||||
if mediaType != "" && !strings.EqualFold(mediaType, ContentTypeJson) && !strings.EqualFold(mediaType, ContentTypeScim) {
|
||||
return false
|
||||
}
|
||||
|
||||
charset, ok := params["charset"]
|
||||
return !ok || strings.EqualFold(charset, "utf-8")
|
||||
}
|
107
internal/api/scim/middleware/content_type_middleware_test.go
Normal file
107
internal/api/scim/middleware/content_type_middleware_test.go
Normal file
@ -0,0 +1,107 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
zhttp "github.com/zitadel/zitadel/internal/api/http"
|
||||
)
|
||||
|
||||
func TestContentTypeMiddleware(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
contentTypeHeader string
|
||||
acceptHeader string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid",
|
||||
contentTypeHeader: "application/scim+json",
|
||||
acceptHeader: "application/scim+json",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "invalid content type",
|
||||
contentTypeHeader: "application/octet-stream",
|
||||
acceptHeader: "application/json",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid accept",
|
||||
contentTypeHeader: "application/json",
|
||||
acceptHeader: "application/octet-stream",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
if tt.acceptHeader != "" {
|
||||
req.Header.Set(zhttp.Accept, tt.acceptHeader)
|
||||
}
|
||||
|
||||
if tt.contentTypeHeader != "" {
|
||||
req.Header.Set(zhttp.ContentType, tt.contentTypeHeader)
|
||||
}
|
||||
|
||||
err := ContentTypeMiddleware(func(w http.ResponseWriter, r *http.Request) error {
|
||||
return nil
|
||||
})(httptest.NewRecorder(), req)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("ContentTypeMiddleware() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_validateContentType(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
contentType string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "empty",
|
||||
contentType: "",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "json",
|
||||
contentType: "application/json",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "scim",
|
||||
contentType: "application/scim+json",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "json utf-8",
|
||||
contentType: "application/json; charset=utf-8",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "scim utf-8",
|
||||
contentType: "application/scim+json; charset=utf-8",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "unknown content type",
|
||||
contentType: "application/octet-stream",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "unknown charset",
|
||||
contentType: "application/scim+json; charset=utf-16",
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := validateContentType(tt.contentType); got != tt.want {
|
||||
t.Errorf("validateContentType() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
54
internal/api/scim/middleware/scim_context_middleware.go
Normal file
54
internal/api/scim/middleware/scim_context_middleware.go
Normal file
@ -0,0 +1,54 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/api/authz"
|
||||
zhttp "github.com/zitadel/zitadel/internal/api/http/middleware"
|
||||
smetadata "github.com/zitadel/zitadel/internal/api/scim/metadata"
|
||||
"github.com/zitadel/zitadel/internal/query"
|
||||
"github.com/zitadel/zitadel/internal/zerrors"
|
||||
)
|
||||
|
||||
func ScimContextMiddleware(q *query.Queries) func(next zhttp.HandlerFuncWithError) zhttp.HandlerFuncWithError {
|
||||
return func(next zhttp.HandlerFuncWithError) zhttp.HandlerFuncWithError {
|
||||
return func(w http.ResponseWriter, r *http.Request) error {
|
||||
ctx, err := initScimContext(r.Context(), q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return next(w, r.WithContext(ctx))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func initScimContext(ctx context.Context, q *query.Queries) (context.Context, error) {
|
||||
data := smetadata.ScimContextData{
|
||||
ProvisioningDomain: "",
|
||||
ExternalIDScopedMetadataKey: smetadata.ScopedKey(smetadata.KeyExternalId),
|
||||
}
|
||||
|
||||
ctx = smetadata.SetScimContextData(ctx, data)
|
||||
|
||||
userID := authz.GetCtxData(ctx).UserID
|
||||
metadata, err := q.GetUserMetadataByKey(ctx, false, userID, string(smetadata.KeyProvisioningDomain), false)
|
||||
if err != nil {
|
||||
if zerrors.IsNotFound(err) {
|
||||
return ctx, nil
|
||||
}
|
||||
|
||||
return ctx, err
|
||||
}
|
||||
|
||||
if metadata == nil {
|
||||
return ctx, nil
|
||||
}
|
||||
|
||||
data.ProvisioningDomain = string(metadata.Value)
|
||||
if data.ProvisioningDomain != "" {
|
||||
data.ExternalIDScopedMetadataKey = smetadata.ScopeExternalIdKey(data.ProvisioningDomain)
|
||||
}
|
||||
return smetadata.SetScimContextData(ctx, data), nil
|
||||
}
|
64
internal/api/scim/resources/resource_handler.go
Normal file
64
internal/api/scim/resources/resource_handler.go
Normal 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)
|
||||
}
|
91
internal/api/scim/resources/resource_handler_adapter.go
Normal file
91
internal/api/scim/resources/resource_handler_adapter.go
Normal 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
|
||||
}
|
212
internal/api/scim/resources/user.go
Normal file
212
internal/api/scim/resources/user.go
Normal 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
|
||||
}
|
366
internal/api/scim/resources/user_mapping.go
Normal file
366
internal/api/scim/resources/user_mapping.go
Normal 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
|
||||
}
|
259
internal/api/scim/resources/user_metadata.go
Normal file
259
internal/api/scim/resources/user_metadata.go
Normal 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)
|
||||
}
|
20
internal/api/scim/schemas/schemas.go
Normal file
20
internal/api/scim/schemas/schemas.go
Normal file
@ -0,0 +1,20 @@
|
||||
package schemas
|
||||
|
||||
type ScimSchemaType string
|
||||
type ScimResourceTypeSingular string
|
||||
type ScimResourceTypePlural string
|
||||
|
||||
const (
|
||||
idPrefixMessages = "urn:ietf:params:scim:api:messages:2.0:"
|
||||
idPrefixCore = "urn:ietf:params:scim:schemas:core:2.0:"
|
||||
idPrefixZitadelMessages = "urn:ietf:params:scim:api:zitadel:messages:2.0:"
|
||||
|
||||
IdUser ScimSchemaType = idPrefixCore + "User"
|
||||
IdError ScimSchemaType = idPrefixMessages + "Error"
|
||||
IdZitadelErrorDetail ScimSchemaType = idPrefixZitadelMessages + "ErrorDetail"
|
||||
|
||||
UserResourceType ScimResourceTypeSingular = "User"
|
||||
UsersResourceType ScimResourceTypePlural = "Users"
|
||||
|
||||
HandlerPrefix = "/scim/v2"
|
||||
)
|
28
internal/api/scim/schemas/string.go
Normal file
28
internal/api/scim/schemas/string.go
Normal file
@ -0,0 +1,28 @@
|
||||
package schemas
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// WriteOnlyString a write only string is not serializable to json.
|
||||
// in the SCIM RFC it has a mutability of writeOnly.
|
||||
// This increases security to really ensure this is never sent to a client.
|
||||
type WriteOnlyString string
|
||||
|
||||
func NewWriteOnlyString(s string) *WriteOnlyString {
|
||||
wos := WriteOnlyString(s)
|
||||
return &wos
|
||||
}
|
||||
|
||||
func (s *WriteOnlyString) MarshalJSON() ([]byte, error) {
|
||||
return []byte("null"), nil
|
||||
}
|
||||
|
||||
func (s *WriteOnlyString) UnmarshalJSON(bytes []byte) error {
|
||||
var str string
|
||||
err := json.Unmarshal(bytes, &str)
|
||||
*s = WriteOnlyString(str)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *WriteOnlyString) String() string {
|
||||
return string(*s)
|
||||
}
|
70
internal/api/scim/schemas/string_test.go
Normal file
70
internal/api/scim/schemas/string_test.go
Normal file
@ -0,0 +1,70 @@
|
||||
package schemas
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestWriteOnlyString_MarshalJSON(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
s WriteOnlyString
|
||||
}{
|
||||
{
|
||||
name: "always returns null",
|
||||
s: "foo bar",
|
||||
},
|
||||
{
|
||||
name: "empty string returns null",
|
||||
s: "",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := json.Marshal(&tt.s)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "null", string(got))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteOnlyString_UnmarshalJSON(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input []byte
|
||||
want WriteOnlyString
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "string",
|
||||
input: []byte(`"fooBar"`),
|
||||
want: "fooBar",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
input: []byte(`""`),
|
||||
want: "",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "bad format",
|
||||
input: []byte(`"bad "format"`),
|
||||
want: "",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var got WriteOnlyString
|
||||
err := json.Unmarshal(tt.input, &got)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("UnmarshalJSON() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
50
internal/api/scim/schemas/url.go
Normal file
50
internal/api/scim/schemas/url.go
Normal file
@ -0,0 +1,50 @@
|
||||
package schemas
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/zerrors"
|
||||
)
|
||||
|
||||
type HttpURL url.URL
|
||||
|
||||
func ParseHTTPURL(rawURL string) (*HttpURL, error) {
|
||||
parsedURL, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
|
||||
return nil, zerrors.ThrowInvalidArgumentf(nil, "SCIM-htturl1", "HTTP URL expected, got %v", parsedURL.Scheme)
|
||||
}
|
||||
|
||||
return (*HttpURL)(parsedURL), nil
|
||||
}
|
||||
|
||||
func (u *HttpURL) UnmarshalJSON(data []byte) error {
|
||||
var urlStr string
|
||||
if err := json.Unmarshal(data, &urlStr); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
parsedURL, err := ParseHTTPURL(urlStr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
*u = *parsedURL
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *HttpURL) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(u.String())
|
||||
}
|
||||
|
||||
func (u *HttpURL) String() string {
|
||||
if u == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return (*url.URL)(u).String()
|
||||
}
|
182
internal/api/scim/schemas/url_test.go
Normal file
182
internal/api/scim/schemas/url_test.go
Normal file
@ -0,0 +1,182 @@
|
||||
package schemas
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/goccy/go-json"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/zitadel/logging"
|
||||
)
|
||||
|
||||
func TestHttpURL_MarshalJSON(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
u *HttpURL
|
||||
want []byte
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "http url",
|
||||
u: mustParseURL("http://example.com"),
|
||||
want: []byte(`"http://example.com"`),
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "https url",
|
||||
u: mustParseURL("https://example.com"),
|
||||
want: []byte(`"https://example.com"`),
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := json.Marshal(tt.u)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("MarshalJSON() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
assert.Equal(t, string(got), string(tt.want))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHttpURL_UnmarshalJSON(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
data []byte
|
||||
want *HttpURL
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "http url",
|
||||
data: []byte(`"http://example.com"`),
|
||||
want: mustParseURL("http://example.com"),
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "https url",
|
||||
data: []byte(`"https://example.com"`),
|
||||
want: mustParseURL("https://example.com"),
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "ftp url should fail",
|
||||
data: []byte(`"ftp://example.com"`),
|
||||
want: nil,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "no url should fail",
|
||||
data: []byte(`"test"`),
|
||||
want: nil,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "number should fail",
|
||||
data: []byte(`120`),
|
||||
want: nil,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
url := new(HttpURL)
|
||||
err := json.Unmarshal(tt.data, url)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("UnmarshalJSON() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
|
||||
if tt.wantErr {
|
||||
return
|
||||
}
|
||||
|
||||
assert.Equal(t, tt.want.String(), url.String())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHttpURL_String(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
u *HttpURL
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "http url",
|
||||
u: mustParseURL("http://example.com"),
|
||||
want: "http://example.com",
|
||||
},
|
||||
{
|
||||
name: "https url",
|
||||
u: mustParseURL("https://example.com"),
|
||||
want: "https://example.com",
|
||||
},
|
||||
{
|
||||
name: "nil",
|
||||
u: nil,
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.u.String(); got != tt.want {
|
||||
t.Errorf("String() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHTTPURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
rawURL string
|
||||
want *HttpURL
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "http url",
|
||||
rawURL: "http://example.com",
|
||||
want: mustParseURL("http://example.com"),
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "https url",
|
||||
rawURL: "https://example.com",
|
||||
want: mustParseURL("https://example.com"),
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "ftp url should fail",
|
||||
rawURL: "ftp://example.com",
|
||||
want: nil,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "no url should fail",
|
||||
rawURL: "test",
|
||||
want: nil,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := ParseHTTPURL(tt.rawURL)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("ParseHTTPURL() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("ParseHTTPURL() got = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func mustParseURL(rawURL string) *HttpURL {
|
||||
url, err := ParseHTTPURL(rawURL)
|
||||
logging.OnError(err).Fatal("failed to parse URL")
|
||||
return url
|
||||
}
|
140
internal/api/scim/serrors/errors.go
Normal file
140
internal/api/scim/serrors/errors.go
Normal file
@ -0,0 +1,140 @@
|
||||
package serrors
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/zitadel/logging"
|
||||
"golang.org/x/text/language"
|
||||
|
||||
http_util "github.com/zitadel/zitadel/internal/api/http"
|
||||
zhttp_middleware "github.com/zitadel/zitadel/internal/api/http/middleware"
|
||||
"github.com/zitadel/zitadel/internal/api/scim/schemas"
|
||||
"github.com/zitadel/zitadel/internal/i18n"
|
||||
"github.com/zitadel/zitadel/internal/zerrors"
|
||||
)
|
||||
|
||||
type scimErrorType string
|
||||
|
||||
type wrappedScimError struct {
|
||||
Parent error
|
||||
ScimType scimErrorType
|
||||
}
|
||||
|
||||
type scimError struct {
|
||||
Schemas []schemas.ScimSchemaType `json:"schemas"`
|
||||
ScimType scimErrorType `json:"scimType,omitempty"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
StatusCode int `json:"-"`
|
||||
Status string `json:"status"`
|
||||
ZitadelDetail *errorDetail `json:"urn:ietf:params:scim:api:zitadel:messages:2.0:ErrorDetail,omitempty"`
|
||||
}
|
||||
|
||||
type errorDetail struct {
|
||||
ID string `json:"id"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
const (
|
||||
// ScimTypeInvalidValue A required value was missing,
|
||||
// or the value specified was not compatible with the operation,
|
||||
// or attribute type (see Section 2.2 of RFC7643),
|
||||
// or resource schema (see Section 4 of RFC7643).
|
||||
ScimTypeInvalidValue scimErrorType = "invalidValue"
|
||||
|
||||
// ScimTypeInvalidSyntax The request body message structure was invalid or did
|
||||
// not conform to the request schema.
|
||||
ScimTypeInvalidSyntax scimErrorType = "invalidSyntax"
|
||||
)
|
||||
|
||||
var translator *i18n.Translator
|
||||
|
||||
func ErrorHandler(next zhttp_middleware.HandlerFuncWithError) http.Handler {
|
||||
var err error
|
||||
translator, err = i18n.NewZitadelTranslator(language.English)
|
||||
logging.OnError(err).Panic("unable to get translator")
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err = next(w, r); err == nil {
|
||||
return
|
||||
}
|
||||
|
||||
scimErr := mapToScimJsonError(r.Context(), err)
|
||||
w.WriteHeader(scimErr.StatusCode)
|
||||
|
||||
jsonErr := json.NewEncoder(w).Encode(scimErr)
|
||||
logging.OnError(jsonErr).Warn("Failed to marshal scim error response")
|
||||
})
|
||||
}
|
||||
|
||||
func ThrowInvalidValue(parent error) error {
|
||||
return &wrappedScimError{
|
||||
Parent: parent,
|
||||
ScimType: ScimTypeInvalidValue,
|
||||
}
|
||||
}
|
||||
|
||||
func ThrowInvalidSyntax(parent error) error {
|
||||
return &wrappedScimError{
|
||||
Parent: parent,
|
||||
ScimType: ScimTypeInvalidSyntax,
|
||||
}
|
||||
}
|
||||
|
||||
func (err *scimError) Error() string {
|
||||
return fmt.Sprintf("SCIM Error: %s: %s", err.ScimType, err.Detail)
|
||||
}
|
||||
|
||||
func (err *wrappedScimError) Error() string {
|
||||
return fmt.Sprintf("SCIM Error: %s: %s", err.ScimType, err.Parent.Error())
|
||||
}
|
||||
|
||||
func mapToScimJsonError(ctx context.Context, err error) *scimError {
|
||||
scimErr := new(wrappedScimError)
|
||||
if ok := errors.As(err, &scimErr); ok {
|
||||
mappedErr := mapToScimJsonError(ctx, scimErr.Parent)
|
||||
mappedErr.ScimType = scimErr.ScimType
|
||||
return mappedErr
|
||||
}
|
||||
|
||||
zitadelErr := new(zerrors.ZitadelError)
|
||||
if ok := errors.As(err, &zitadelErr); !ok {
|
||||
return &scimError{
|
||||
Schemas: []schemas.ScimSchemaType{schemas.IdError},
|
||||
Detail: "Unknown internal server error",
|
||||
Status: strconv.Itoa(http.StatusInternalServerError),
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
}
|
||||
}
|
||||
|
||||
statusCode, ok := http_util.ZitadelErrorToHTTPStatusCode(err)
|
||||
if !ok {
|
||||
statusCode = http.StatusInternalServerError
|
||||
}
|
||||
|
||||
localizedMsg := translator.LocalizeFromCtx(ctx, zitadelErr.GetMessage(), nil)
|
||||
return &scimError{
|
||||
Schemas: []schemas.ScimSchemaType{schemas.IdError, schemas.IdZitadelErrorDetail},
|
||||
ScimType: mapErrorToScimErrorType(err),
|
||||
Detail: localizedMsg,
|
||||
StatusCode: statusCode,
|
||||
Status: strconv.Itoa(statusCode),
|
||||
ZitadelDetail: &errorDetail{
|
||||
ID: zitadelErr.GetID(),
|
||||
Message: zitadelErr.GetMessage(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func mapErrorToScimErrorType(err error) scimErrorType {
|
||||
switch {
|
||||
case zerrors.IsErrorInvalidArgument(err):
|
||||
return ScimTypeInvalidValue
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
110
internal/api/scim/serrors/errors_test.go
Normal file
110
internal/api/scim/serrors/errors_test.go
Normal file
@ -0,0 +1,110 @@
|
||||
package serrors
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/i18n"
|
||||
"github.com/zitadel/zitadel/internal/zerrors"
|
||||
)
|
||||
|
||||
func TestErrorHandler(t *testing.T) {
|
||||
i18n.MustLoadSupportedLanguagesFromDir()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
wantStatus int
|
||||
wantBody string
|
||||
}{
|
||||
{
|
||||
name: "scim error",
|
||||
err: ThrowInvalidSyntax(zerrors.ThrowInvalidArgument(nil, "FOO", "Invalid syntax")),
|
||||
wantStatus: http.StatusBadRequest,
|
||||
wantBody: `{
|
||||
"schemas":[
|
||||
"urn:ietf:params:scim:api:messages:2.0:Error",
|
||||
"urn:ietf:params:scim:api:zitadel:messages:2.0:ErrorDetail"
|
||||
],
|
||||
"scimType":"invalidSyntax",
|
||||
"detail":"Invalid syntax",
|
||||
"status":"400",
|
||||
"urn:ietf:params:scim:api:zitadel:messages:2.0:ErrorDetail": {
|
||||
"id":"FOO",
|
||||
"message":"Invalid syntax"
|
||||
}
|
||||
}`,
|
||||
},
|
||||
{
|
||||
name: "zitadel error",
|
||||
err: zerrors.ThrowInvalidArgument(nil, "FOO", "Invalid syntax"),
|
||||
wantStatus: http.StatusBadRequest,
|
||||
wantBody: `{
|
||||
"schemas":[
|
||||
"urn:ietf:params:scim:api:messages:2.0:Error",
|
||||
"urn:ietf:params:scim:api:zitadel:messages:2.0:ErrorDetail"
|
||||
],
|
||||
"scimType":"invalidValue",
|
||||
"detail":"Invalid syntax",
|
||||
"status":"400",
|
||||
"urn:ietf:params:scim:api:zitadel:messages:2.0:ErrorDetail": {
|
||||
"id":"FOO",
|
||||
"message":"Invalid syntax"
|
||||
}
|
||||
}`,
|
||||
},
|
||||
{
|
||||
name: "zitadel internal error",
|
||||
err: zerrors.ThrowInternal(nil, "FOO", "Internal error"),
|
||||
wantStatus: http.StatusInternalServerError,
|
||||
wantBody: `{
|
||||
"schemas":[
|
||||
"urn:ietf:params:scim:api:messages:2.0:Error",
|
||||
"urn:ietf:params:scim:api:zitadel:messages:2.0:ErrorDetail"
|
||||
],
|
||||
"detail":"Internal error",
|
||||
"status":"500",
|
||||
"urn:ietf:params:scim:api:zitadel:messages:2.0:ErrorDetail": {
|
||||
"id":"FOO",
|
||||
"message":"Internal error"
|
||||
}
|
||||
}`,
|
||||
},
|
||||
{
|
||||
name: "unknown error",
|
||||
err: errors.New("FOO"),
|
||||
wantStatus: http.StatusInternalServerError,
|
||||
wantBody: `{
|
||||
"schemas":[
|
||||
"urn:ietf:params:scim:api:messages:2.0:Error"
|
||||
],
|
||||
"detail":"Unknown internal server error",
|
||||
"status":"500"
|
||||
}`,
|
||||
},
|
||||
{
|
||||
name: "no error",
|
||||
err: nil,
|
||||
wantStatus: http.StatusOK,
|
||||
wantBody: "",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
ErrorHandler(func(http.ResponseWriter, *http.Request) error {
|
||||
return tt.err
|
||||
}).ServeHTTP(recorder, req)
|
||||
assert.Equal(t, tt.wantStatus, recorder.Code)
|
||||
|
||||
if tt.wantBody != "" {
|
||||
assert.JSONEq(t, tt.wantBody, recorder.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
105
internal/api/scim/server.go
Normal file
105
internal/api/scim/server.go
Normal 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
Loading…
x
Reference in New Issue
Block a user