diff --git a/cmd/defaults.yaml b/cmd/defaults.yaml index f85033069a..30e037c80f 100644 --- a/cmd/defaults.yaml +++ b/cmd/defaults.yaml @@ -1713,6 +1713,298 @@ InternalAuthZ: - "user.grant.read" - "user.membership.read" +SystemAuthZ: + RolePermissionMappings: + - Role: "SYSTEM_OWNER" + Permissions: + - "system.instance.read" + - "system.instance.write" + - "system.instance.delete" + - "system.domain.read" + - "system.domain.write" + - "system.domain.delete" + - "system.debug.read" + - "system.debug.write" + - "system.debug.delete" + - "system.feature.read" + - "system.feature.write" + - "system.feature.delete" + - "system.limits.write" + - "system.limits.delete" + - "system.quota.write" + - "system.quota.delete" + - "system.iam.member.read" + - Role: "SYSTEM_OWNER_VIEWER" + Permissions: + - "system.instance.read" + - "system.domain.read" + - "system.debug.read" + - "system.feature.read" + - "system.iam.member.read" + - Role: "IAM_OWNER" + Permissions: + - "iam.read" + - "iam.write" + - "iam.policy.read" + - "iam.policy.write" + - "iam.policy.delete" + - "iam.member.read" + - "iam.member.write" + - "iam.member.delete" + - "iam.idp.read" + - "iam.idp.write" + - "iam.idp.delete" + - "iam.action.read" + - "iam.action.write" + - "iam.action.delete" + - "iam.flow.read" + - "iam.flow.write" + - "iam.flow.delete" + - "iam.feature.read" + - "iam.feature.write" + - "iam.feature.delete" + - "iam.restrictions.read" + - "iam.restrictions.write" + - "iam.web_key.write" + - "iam.web_key.delete" + - "iam.web_key.read" + - "iam.debug.write" + - "iam.debug.read" + - "org.read" + - "org.global.read" + - "org.create" + - "org.write" + - "org.delete" + - "org.member.read" + - "org.member.write" + - "org.member.delete" + - "org.idp.read" + - "org.idp.write" + - "org.idp.delete" + - "org.action.read" + - "org.action.write" + - "org.action.delete" + - "org.flow.read" + - "org.flow.write" + - "org.flow.delete" + - "org.feature.read" + - "org.feature.write" + - "org.feature.delete" + - "user.read" + - "user.global.read" + - "user.write" + - "user.delete" + - "user.grant.read" + - "user.grant.write" + - "user.grant.delete" + - "user.membership.read" + - "user.credential.write" + - "user.passkey.write" + - "user.feature.read" + - "user.feature.write" + - "user.feature.delete" + - "policy.read" + - "policy.write" + - "policy.delete" + - "project.read" + - "project.create" + - "project.write" + - "project.delete" + - "project.member.read" + - "project.member.write" + - "project.member.delete" + - "project.role.read" + - "project.role.write" + - "project.role.delete" + - "project.app.read" + - "project.app.write" + - "project.app.delete" + - "project.grant.read" + - "project.grant.write" + - "project.grant.delete" + - "project.grant.member.read" + - "project.grant.member.write" + - "project.grant.member.delete" + - "events.read" + - "milestones.read" + - "session.read" + - "session.delete" + - "action.target.read" + - "action.target.write" + - "action.target.delete" + - "action.execution.read" + - "action.execution.write" + - "userschema.read" + - "userschema.write" + - "userschema.delete" + - "session.read" + - "session.delete" + - Role: "IAM_OWNER_VIEWER" + Permissions: + - "iam.read" + - "iam.policy.read" + - "iam.member.read" + - "iam.idp.read" + - "iam.action.read" + - "iam.flow.read" + - "iam.restrictions.read" + - "iam.feature.read" + - "iam.web_key.read" + - "iam.debug.read" + - "org.read" + - "org.member.read" + - "org.idp.read" + - "org.action.read" + - "org.flow.read" + - "org.feature.read" + - "user.read" + - "user.global.read" + - "user.grant.read" + - "user.membership.read" + - "user.feature.read" + - "policy.read" + - "project.read" + - "project.member.read" + - "project.role.read" + - "project.app.read" + - "project.grant.read" + - "project.grant.member.read" + - "events.read" + - "milestones.read" + - "action.target.read" + - "action.execution.read" + - "userschema.read" + - "session.read" + - Role: "IAM_ORG_MANAGER" + Permissions: + - "org.read" + - "org.global.read" + - "org.create" + - "org.write" + - "org.delete" + - "org.member.read" + - "org.member.write" + - "org.member.delete" + - "org.idp.read" + - "org.idp.write" + - "org.idp.delete" + - "org.action.read" + - "org.action.write" + - "org.action.delete" + - "org.flow.read" + - "org.flow.write" + - "org.flow.delete" + - "org.feature.read" + - "org.feature.write" + - "org.feature.delete" + - "user.read" + - "user.global.read" + - "user.write" + - "user.delete" + - "user.grant.read" + - "user.grant.write" + - "user.grant.delete" + - "user.membership.read" + - "user.credential.write" + - "user.passkey.write" + - "user.feature.read" + - "user.feature.write" + - "user.feature.delete" + - "policy.read" + - "policy.write" + - "policy.delete" + - "project.read" + - "project.create" + - "project.write" + - "project.delete" + - "project.member.read" + - "project.member.write" + - "project.member.delete" + - "project.role.read" + - "project.role.write" + - "project.role.delete" + - "project.app.read" + - "project.app.write" + - "project.app.delete" + - "project.grant.read" + - "project.grant.write" + - "project.grant.delete" + - "project.grant.member.read" + - "project.grant.member.write" + - "project.grant.member.delete" + - "session.delete" + - Role: "IAM_USER_MANAGER" + Permissions: + - "org.read" + - "org.global.read" + - "org.member.read" + - "org.member.delete" + - "user.read" + - "user.global.read" + - "user.write" + - "user.delete" + - "user.grant.read" + - "user.grant.write" + - "user.grant.delete" + - "user.membership.read" + - "user.passkey.write" + - "user.feature.read" + - "user.feature.write" + - "user.feature.delete" + - "project.read" + - "project.member.read" + - "project.role.read" + - "project.app.read" + - "project.grant.read" + - "project.grant.write" + - "project.grant.delete" + - "project.grant.member.read" + - "session.delete" + - Role: "IAM_ADMIN_IMPERSONATOR" + Permissions: + - "admin.impersonation" + - "impersonation" + - Role: "IAM_END_USER_IMPERSONATOR" + Permissions: + - "impersonation" + - Role: "IAM_LOGIN_CLIENT" + Permissions: + - "iam.read" + - "iam.policy.read" + - "iam.member.read" + - "iam.member.write" + - "iam.idp.read" + - "iam.feature.read" + - "iam.restrictions.read" + - "org.read" + - "org.member.read" + - "org.member.write" + - "org.idp.read" + - "org.feature.read" + - "user.read" + - "user.write" + - "user.grant.read" + - "user.grant.write" + - "user.membership.read" + - "user.credential.write" + - "user.passkey.write" + - "user.feature.read" + - "policy.read" + - "project.read" + - "project.member.read" + - "project.member.write" + - "project.role.read" + - "project.app.read" + - "project.member.read" + - "project.member.write" + - "project.grant.read" + - "project.grant.member.read" + - "project.grant.member.write" + - "session.read" + - "session.link" + - "session.delete" + - "userschema.read" + # If a new projection is introduced it will be prefilled during the setup process (if enabled) # This can prevent serving outdated data after a version upgrade, but might require a longer setup / upgrade process: # https://zitadel.com/docs/self-hosting/manage/updating_scaling diff --git a/cmd/mirror/projections.go b/cmd/mirror/projections.go index 14b93b52c8..66b3fb1a26 100644 --- a/cmd/mirror/projections.go +++ b/cmd/mirror/projections.go @@ -84,6 +84,7 @@ type ProjectionsConfig struct { ExternalDomain string ExternalSecure bool InternalAuthZ internal_authz.Config + SystemAuthZ internal_authz.Config SystemDefaults systemdefaults.SystemDefaults Telemetry *handlers.TelemetryPusherConfig Login login.Config @@ -150,7 +151,7 @@ func projections( sessionTokenVerifier, func(q *query.Queries) domain.PermissionCheck { return func(ctx context.Context, permission, orgID, resourceID string) (err error) { - return internal_authz.CheckPermission(ctx, &authz_es.UserMembershipRepo{Queries: q}, config.InternalAuthZ.RolePermissionMappings, permission, orgID, resourceID) + return internal_authz.CheckPermission(ctx, &authz_es.UserMembershipRepo{Queries: q}, config.SystemAuthZ.RolePermissionMappings, config.InternalAuthZ.RolePermissionMappings, permission, orgID, resourceID) } }, 0, @@ -187,7 +188,7 @@ func projections( keys.Target, &http.Client{}, func(ctx context.Context, permission, orgID, resourceID string) (err error) { - return internal_authz.CheckPermission(ctx, authZRepo, config.InternalAuthZ.RolePermissionMappings, permission, orgID, resourceID) + return internal_authz.CheckPermission(ctx, authZRepo, config.SystemAuthZ.RolePermissionMappings, config.InternalAuthZ.RolePermissionMappings, permission, orgID, resourceID) }, sessionTokenVerifier, config.OIDC.DefaultAccessTokenLifetime, diff --git a/cmd/setup/52.go b/cmd/setup/52.go new file mode 100644 index 0000000000..581a7acbab --- /dev/null +++ b/cmd/setup/52.go @@ -0,0 +1,37 @@ +package setup + +import ( + "context" + "embed" + "fmt" + + "github.com/zitadel/logging" + + "github.com/zitadel/zitadel/internal/database" + "github.com/zitadel/zitadel/internal/eventstore" +) + +type InitPermittedOrgsFunction52 struct { + dbClient *database.DB +} + +//go:embed 52/*.sql +var permittedOrgsFunction52 embed.FS + +func (mig *InitPermittedOrgsFunction52) Execute(ctx context.Context, _ eventstore.Event) error { + statements, err := readStatements(permittedOrgsFunction52, "52") + if err != nil { + return err + } + for _, stmt := range statements { + logging.WithFields("file", stmt.file, "migration", mig.String()).Info("execute statement") + if _, err := mig.dbClient.ExecContext(ctx, stmt.query); err != nil { + return fmt.Errorf("%s %s: %w", mig.String(), stmt.file, err) + } + } + return nil +} + +func (*InitPermittedOrgsFunction52) String() string { + return "52_init_permitted_orgs_function" +} diff --git a/cmd/setup/52/01-get-permissions-from-JSON.sql b/cmd/setup/52/01-get-permissions-from-JSON.sql new file mode 100644 index 0000000000..b6415fa180 --- /dev/null +++ b/cmd/setup/52/01-get-permissions-from-JSON.sql @@ -0,0 +1,43 @@ +DROP FUNCTION IF EXISTS eventstore.get_system_permissions; + +CREATE OR REPLACE FUNCTION eventstore.get_system_permissions( + permissions_json JSONB + /* + [ + { + "member_type": "System", + "aggregate_id": "", + "object_id": "", + "permissions": ["iam.read", "iam.write", "iam.polic.read"] + }, + { + "member_type": "IAM", + "aggregate_id": "310716990375453665", + "object_id": "", + "permissions": ["iam.read", "iam.write", "iam.polic.read"] + } + ] + */ + , permm TEXT +) +RETURNS TABLE ( + member_type TEXT, + aggregate_id TEXT, + object_id TEXT +) + LANGUAGE 'plpgsql' +AS $$ +BEGIN + RETURN QUERY + SELECT res.member_type, res.aggregate_id, res.object_id FROM ( + SELECT + (perm)->>'member_type' AS member_type, + (perm)->>'aggregate_id' AS aggregate_id, + (perm)->>'object_id' AS object_id, + permission + FROM jsonb_array_elements(permissions_json) AS perm + CROSS JOIN jsonb_array_elements_text(perm->'permissions') AS permission) AS res + WHERE res. permission= permm; +END; +$$; + diff --git a/cmd/setup/52/02-permitted_orgs_function.sql b/cmd/setup/52/02-permitted_orgs_function.sql new file mode 100644 index 0000000000..b6f61c6225 --- /dev/null +++ b/cmd/setup/52/02-permitted_orgs_function.sql @@ -0,0 +1,144 @@ +DROP FUNCTION IF EXISTS eventstore.check_system_user_perms; + +CREATE OR REPLACE FUNCTION eventstore.check_system_user_perms( + system_user_perms JSONB + , perm TEXT + , filter_orgs TEXT + + , org_ids OUT TEXT[] +) + LANGUAGE 'plpgsql' +AS $$ +BEGIN + + WITH found_permissions(member_type, aggregate_id, object_id ) AS ( + SELECT * FROM eventstore.get_system_permissions( + system_user_perms, + perm) + ) + + SELECT array_agg(DISTINCT o.org_id) INTO org_ids + FROM eventstore.instance_orgs o, found_permissions + WHERE + CASE WHEN (SELECT TRUE WHERE found_permissions.member_type = 'System' LIMIT 1) THEN + TRUE + WHEN (SELECT TRUE WHERE found_permissions.member_type = 'IAM' LIMIT 1) THEN + -- aggregate_id not present + CASE WHEN (SELECT TRUE WHERE '' = ANY ( + ( + SELECT array_agg(found_permissions.aggregate_id) + FROM found_permissions + WHERE member_type = 'IAM' + GROUP BY member_type + LIMIT 1 + )::TEXT[])) THEN + TRUE + -- aggregate_id is present + ELSE + o.instance_id = ANY ( + ( + SELECT array_agg(found_permissions.aggregate_id) + FROM found_permissions + WHERE member_type = 'IAM' + GROUP BY member_type + LIMIT 1 + )::TEXT[]) + END + WHEN (SELECT TRUE WHERE found_permissions.member_type = 'Organization' LIMIT 1) THEN + -- aggregate_id not present + CASE WHEN (SELECT TRUE WHERE '' = ANY ( + ( + SELECT array_agg(found_permissions.aggregate_id) + FROM found_permissions + WHERE member_type = 'Organization' + GROUP BY member_type + LIMIT 1 + )::TEXT[])) THEN + TRUE + -- aggregate_id is present + ELSE + o.org_id = ANY ( + ( + SELECT array_agg(found_permissions.aggregate_id) + FROM found_permissions + WHERE member_type = 'Organization' + GROUP BY member_type + LIMIT 1 + )::TEXT[]) + END + END + AND + CASE WHEN filter_orgs != '' + THEN o.org_id IN (filter_orgs) + ELSE TRUE END + LIMIT 1; +END; +$$; + + +DROP FUNCTION IF EXISTS eventstore.permitted_orgs; + +CREATE OR REPLACE FUNCTION eventstore.permitted_orgs( + instanceId TEXT + , userId TEXT + , system_user_perms JSONB + , perm TEXT + , filter_orgs TEXT + + , org_ids OUT TEXT[] +) + LANGUAGE 'plpgsql' +AS $$ +BEGIN + + -- if system user + IF system_user_perms IS NOT NULL THEN + org_ids := eventstore.check_system_user_perms(system_user_perms, perm, filter_orgs); + -- if human/machine user + ELSE + 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 or only those in filter_orgs + SELECT array_agg(o.org_id) INTO org_ids + FROM eventstore.instance_orgs o + WHERE o.instance_id = instanceId + AND CASE WHEN filter_orgs != '' + THEN o.org_id IN (filter_orgs) + ELSE TRUE END; + RETURN; + END IF; + END; + + -- Return the organizations where permission were granted thru org-level roles + SELECT array_agg(sub.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 sub; + END; + END IF; +END; +$$; + diff --git a/cmd/setup/config.go b/cmd/setup/config.go index 1c5e03cca3..9754494f9c 100644 --- a/cmd/setup/config.go +++ b/cmd/setup/config.go @@ -12,7 +12,7 @@ import ( "github.com/zitadel/zitadel/cmd/encryption" "github.com/zitadel/zitadel/cmd/hooks" "github.com/zitadel/zitadel/internal/actions" - internal_authz "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/api/oidc" "github.com/zitadel/zitadel/internal/api/ui/login" "github.com/zitadel/zitadel/internal/cache/connector" @@ -34,7 +34,8 @@ type Config struct { Database database.Config Caches *connector.CachesConfig SystemDefaults systemdefaults.SystemDefaults - InternalAuthZ internal_authz.Config + InternalAuthZ authz.Config + SystemAuthZ authz.Config ExternalDomain string ExternalPort uint16 ExternalSecure bool @@ -53,7 +54,7 @@ type Config struct { Login login.Config WebAuthNName string Telemetry *handlers.TelemetryPusherConfig - SystemAPIUsers map[string]*internal_authz.SystemAPIUser + SystemAPIUsers map[string]*authz.SystemAPIUser } type InitProjections struct { @@ -68,12 +69,12 @@ func MustNewConfig(v *viper.Viper) *Config { err := v.Unmarshal(config, viper.DecodeHook(mapstructure.ComposeDecodeHookFunc( hooks.SliceTypeStringDecode[*domain.CustomMessageText], - hooks.SliceTypeStringDecode[internal_authz.RoleMapping], - hooks.MapTypeStringDecode[string, *internal_authz.SystemAPIUser], + hooks.SliceTypeStringDecode[authz.RoleMapping], + hooks.MapTypeStringDecode[string, *authz.SystemAPIUser], hooks.MapHTTPHeaderStringDecode, database.DecodeHook(false), actions.HTTPConfigDecodeHook, - hook.EnumHookFunc(internal_authz.MemberTypeString), + hook.EnumHookFunc(authz.MemberTypeString), hook.Base64ToBytesHookFunc(), hook.TagToLanguageHookFunc(), mapstructure.StringToTimeDurationHookFunc(), @@ -142,6 +143,7 @@ type Steps struct { s49InitPermittedOrgsFunction *InitPermittedOrgsFunction s50IDPTemplate6UsePKCE *IDPTemplate6UsePKCE s51IDPTemplate6RootCA *IDPTemplate6RootCA + s52InitPermittedOrgsFunction *InitPermittedOrgsFunction52 } func MustNewSteps(v *viper.Viper) *Steps { diff --git a/cmd/setup/setup.go b/cmd/setup/setup.go index 6d5a9357cf..1a6cafbc64 100644 --- a/cmd/setup/setup.go +++ b/cmd/setup/setup.go @@ -178,6 +178,7 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string) steps.s49InitPermittedOrgsFunction = &InitPermittedOrgsFunction{eventstoreClient: dbClient} steps.s50IDPTemplate6UsePKCE = &IDPTemplate6UsePKCE{dbClient: dbClient} steps.s51IDPTemplate6RootCA = &IDPTemplate6RootCA{dbClient: dbClient} + steps.s52InitPermittedOrgsFunction = &InitPermittedOrgsFunction52{dbClient: dbClient} err = projection.Create(ctx, dbClient, eventstoreClient, config.Projections, nil, nil, nil) logging.OnError(err).Fatal("unable to start projections") @@ -218,6 +219,7 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string) steps.s49InitPermittedOrgsFunction, steps.s50IDPTemplate6UsePKCE, steps.s51IDPTemplate6RootCA, + steps.s52InitPermittedOrgsFunction, } { mustExecuteMigration(ctx, eventstoreClient, step, "migration failed") } @@ -409,7 +411,7 @@ func startCommandsQueries( sessionTokenVerifier, func(q *query.Queries) domain.PermissionCheck { return func(ctx context.Context, permission, orgID, resourceID string) (err error) { - return internal_authz.CheckPermission(ctx, &authz_es.UserMembershipRepo{Queries: q}, config.InternalAuthZ.RolePermissionMappings, permission, orgID, resourceID) + return internal_authz.CheckPermission(ctx, &authz_es.UserMembershipRepo{Queries: q}, config.SystemAuthZ.RolePermissionMappings, config.InternalAuthZ.RolePermissionMappings, permission, orgID, resourceID) } }, 0, // not needed for projections @@ -434,7 +436,7 @@ func startCommandsQueries( authZRepo, err := authz.Start(queries, eventstoreClient, dbClient, keys.OIDC, config.ExternalSecure) logging.OnError(err).Fatal("unable to start authz repo") permissionCheck := func(ctx context.Context, permission, orgID, resourceID string) (err error) { - return internal_authz.CheckPermission(ctx, authZRepo, config.InternalAuthZ.RolePermissionMappings, permission, orgID, resourceID) + return internal_authz.CheckPermission(ctx, authZRepo, config.SystemAuthZ.RolePermissionMappings, config.InternalAuthZ.RolePermissionMappings, permission, orgID, resourceID) } commands, err := command.StartCommands(ctx, diff --git a/cmd/start/config.go b/cmd/start/config.go index 589086b801..e973c40479 100644 --- a/cmd/start/config.go +++ b/cmd/start/config.go @@ -11,7 +11,7 @@ import ( "github.com/zitadel/zitadel/cmd/hooks" "github.com/zitadel/zitadel/internal/actions" admin_es "github.com/zitadel/zitadel/internal/admin/repository/eventsourcing" - internal_authz "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/api/http/middleware" "github.com/zitadel/zitadel/internal/api/oidc" "github.com/zitadel/zitadel/internal/api/saml" @@ -67,12 +67,13 @@ type Config struct { Login login.Config Console console.Config AssetStorage static_config.AssetStorageConfig - InternalAuthZ internal_authz.Config + InternalAuthZ authz.Config + SystemAuthZ authz.Config SystemDefaults systemdefaults.SystemDefaults EncryptionKeys *encryption.EncryptionKeyConfig DefaultInstance command.InstanceSetup AuditLogRetention time.Duration - SystemAPIUsers map[string]*internal_authz.SystemAPIUser + SystemAPIUsers map[string]*authz.SystemAPIUser CustomerPortal string Machine *id.Config Actions *actions.Config @@ -96,12 +97,12 @@ func MustNewConfig(v *viper.Viper) *Config { err := v.Unmarshal(config, viper.DecodeHook(mapstructure.ComposeDecodeHookFunc( hooks.SliceTypeStringDecode[*domain.CustomMessageText], - hooks.SliceTypeStringDecode[internal_authz.RoleMapping], - hooks.MapTypeStringDecode[string, *internal_authz.SystemAPIUser], + hooks.SliceTypeStringDecode[authz.RoleMapping], + hooks.MapTypeStringDecode[string, *authz.SystemAPIUser], hooks.MapHTTPHeaderStringDecode, database.DecodeHook(false), actions.HTTPConfigDecodeHook, - hook.EnumHookFunc(internal_authz.MemberTypeString), + hook.EnumHookFunc(authz.MemberTypeString), hooks.MapTypeStringDecode[domain.Feature, any], hooks.SliceTypeStringDecode[*command.SetQuota], hook.Base64ToBytesHookFunc(), diff --git a/cmd/start/start.go b/cmd/start/start.go index 84dda13c54..e3d84625b4 100644 --- a/cmd/start/start.go +++ b/cmd/start/start.go @@ -56,7 +56,7 @@ import ( "github.com/zitadel/zitadel/internal/api/grpc/system" user_v2 "github.com/zitadel/zitadel/internal/api/grpc/user/v2" user_v2beta "github.com/zitadel/zitadel/internal/api/grpc/user/v2beta" - "github.com/zitadel/zitadel/internal/api/grpc/webkey/v2beta" + webkey "github.com/zitadel/zitadel/internal/api/grpc/webkey/v2beta" http_util "github.com/zitadel/zitadel/internal/api/http" "github.com/zitadel/zitadel/internal/api/http/middleware" "github.com/zitadel/zitadel/internal/api/idp" @@ -193,7 +193,7 @@ func startZitadel(ctx context.Context, config *Config, masterKey string, server sessionTokenVerifier, func(q *query.Queries) domain.PermissionCheck { return func(ctx context.Context, permission, orgID, resourceID string) (err error) { - return internal_authz.CheckPermission(ctx, &authz_es.UserMembershipRepo{Queries: q}, config.InternalAuthZ.RolePermissionMappings, permission, orgID, resourceID) + return internal_authz.CheckPermission(ctx, &authz_es.UserMembershipRepo{Queries: q}, config.SystemAuthZ.RolePermissionMappings, config.InternalAuthZ.RolePermissionMappings, permission, orgID, resourceID) } }, config.AuditLogRetention, @@ -209,7 +209,7 @@ func startZitadel(ctx context.Context, config *Config, masterKey string, server return fmt.Errorf("error starting authz repo: %w", err) } permissionCheck := func(ctx context.Context, permission, orgID, resourceID string) (err error) { - return internal_authz.CheckPermission(ctx, authZRepo, config.InternalAuthZ.RolePermissionMappings, permission, orgID, resourceID) + return internal_authz.CheckPermission(ctx, authZRepo, config.SystemAuthZ.RolePermissionMappings, config.InternalAuthZ.RolePermissionMappings, permission, orgID, resourceID) } storage, err := config.AssetStorage.NewStorage(dbClient.DB) @@ -418,7 +418,7 @@ func startAPIs( http_util.WithMaxAge(int(math.Floor(config.Quotas.Access.ExhaustedCookieMaxAge.Seconds()))), ) limitingAccessInterceptor := middleware.NewAccessInterceptor(accessSvc, exhaustedCookieHandler, &config.Quotas.Access.AccessConfig) - apis, err := api.New(ctx, config.Port, router, queries, verifier, config.InternalAuthZ, tlsConfig, config.ExternalDomain, append(config.InstanceHostHeaders, config.PublicHostHeaders...), limitingAccessInterceptor) + apis, err := api.New(ctx, config.Port, router, queries, verifier, config.SystemAuthZ, config.InternalAuthZ, tlsConfig, config.ExternalDomain, append(config.InstanceHostHeaders, config.PublicHostHeaders...), limitingAccessInterceptor) if err != nil { return nil, fmt.Errorf("error creating api %w", err) } @@ -501,7 +501,7 @@ func startAPIs( } instanceInterceptor := middleware.InstanceInterceptor(queries, config.ExternalDomain, login.IgnoreInstanceEndpoints...) assetsCache := middleware.AssetsCacheInterceptor(config.AssetStorage.Cache.MaxAge, config.AssetStorage.Cache.SharedMaxAge) - apis.RegisterHandlerOnPrefix(assets.HandlerPrefix, assets.NewHandler(commands, verifier, config.InternalAuthZ, id.SonyFlakeGenerator(), store, queries, middleware.CallDurationHandler, instanceInterceptor.Handler, assetsCache.Handler, limitingAccessInterceptor.Handle)) + apis.RegisterHandlerOnPrefix(assets.HandlerPrefix, assets.NewHandler(commands, verifier, config.SystemAuthZ, config.InternalAuthZ, id.SonyFlakeGenerator(), store, queries, middleware.CallDurationHandler, instanceInterceptor.Handler, assetsCache.Handler, limitingAccessInterceptor.Handle)) apis.RegisterHandlerOnPrefix(idp.HandlerPrefix, idp.NewHandler(commands, queries, keys.IDPConfig, instanceInterceptor.Handler)) @@ -545,7 +545,7 @@ func startAPIs( keys.User, &config.SCIM, instanceInterceptor.HandlerFuncWithError, - middleware.AuthorizationInterceptor(verifier, config.InternalAuthZ).HandlerFuncWithError)) + middleware.AuthorizationInterceptor(verifier, config.SystemAuthZ, config.InternalAuthZ).HandlerFuncWithError)) c, err := console.Start(config.Console, config.ExternalSecure, oidcServer.IssuerFromRequest, middleware.CallDurationHandler, instanceInterceptor.Handler, limitingAccessInterceptor, config.CustomerPortal) if err != nil { @@ -611,7 +611,7 @@ func listen(ctx context.Context, router *mux.Router, port uint16, tlsConfig *tls go func() { logging.Infof("server is listening on %s", lis.Addr().String()) if tlsConfig != nil { - //we don't need to pass the files here, because we already initialized the TLS config on the server + // we don't need to pass the files here, because we already initialized the TLS config on the server errCh <- http1Server.ServeTLS(lis, "", "") } else { errCh <- http1Server.Serve(lis) diff --git a/docs/docs/guides/integrate/zitadel-apis/access-zitadel-system-api.md b/docs/docs/guides/integrate/zitadel-apis/access-zitadel-system-api.md index 4e2b0b9973..4b96e9bbd5 100644 --- a/docs/docs/guides/integrate/zitadel-apis/access-zitadel-system-api.md +++ b/docs/docs/guides/integrate/zitadel-apis/access-zitadel-system-api.md @@ -16,6 +16,8 @@ To authenticate the user a self-signed JWT will be created and utilized. You can define any id for your user. This guide will assume it's `system-user-1`. +**NOTE:** system user id cannot contain capital letters + ## Generate an RSA keypair Generate an RSA private key with 2048 bit modulus: diff --git a/internal/api/api.go b/internal/api/api.go index 15d6c5b996..62d3e14b35 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -15,7 +15,7 @@ import ( healthpb "google.golang.org/grpc/health/grpc_health_v1" "google.golang.org/grpc/reflection" - internal_authz "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/api/grpc/server" http_util "github.com/zitadel/zitadel/internal/api/http" http_mw "github.com/zitadel/zitadel/internal/api/http/middleware" @@ -29,7 +29,7 @@ import ( type API struct { port uint16 grpcServer *grpc.Server - verifier internal_authz.APITokenVerifier + verifier authz.APITokenVerifier health healthCheck router *mux.Router hostHeaders []string @@ -72,8 +72,9 @@ func New( port uint16, router *mux.Router, queries *query.Queries, - verifier internal_authz.APITokenVerifier, - authZ internal_authz.Config, + verifier authz.APITokenVerifier, + systemAuthz authz.Config, + authZ authz.Config, tlsConfig *tls.Config, externalDomain string, hostHeaders []string, @@ -89,7 +90,7 @@ func New( hostHeaders: hostHeaders, } - api.grpcServer = server.CreateServer(api.verifier, authZ, queries, externalDomain, tlsConfig, accessInterceptor.AccessService()) + api.grpcServer = server.CreateServer(api.verifier, systemAuthz, authZ, queries, externalDomain, tlsConfig, accessInterceptor.AccessService()) api.grpcGateway, err = server.CreateGateway(ctx, port, hostHeaders, accessInterceptor, tlsConfig) if err != nil { return nil, err diff --git a/internal/api/assets/asset.go b/internal/api/assets/asset.go index 57ad3710bc..8c3c51c6aa 100644 --- a/internal/api/assets/asset.go +++ b/internal/api/assets/asset.go @@ -94,13 +94,13 @@ func DefaultErrorHandler(translator *i18n.Translator) func(w http.ResponseWriter } } -func NewHandler(commands *command.Commands, verifier authz.APITokenVerifier, authConfig authz.Config, idGenerator id.Generator, storage static.Storage, queries *query.Queries, callDurationInterceptor, instanceInterceptor, assetCacheInterceptor, accessInterceptor func(handler http.Handler) http.Handler) http.Handler { +func NewHandler(commands *command.Commands, verifier authz.APITokenVerifier, systemAuthCOnfig authz.Config, authConfig authz.Config, idGenerator id.Generator, storage static.Storage, queries *query.Queries, callDurationInterceptor, instanceInterceptor, assetCacheInterceptor, accessInterceptor func(handler http.Handler) http.Handler) http.Handler { translator, err := i18n.NewZitadelTranslator(language.English) logging.OnError(err).Panic("unable to get translator") h := &Handler{ commands: commands, errorHandler: DefaultErrorHandler(translator), - authInterceptor: http_mw.AuthorizationInterceptor(verifier, authConfig), + authInterceptor: http_mw.AuthorizationInterceptor(verifier, systemAuthCOnfig, authConfig), idGenerator: idGenerator, storage: storage, query: queries, @@ -129,8 +129,10 @@ func (l *publicFileDownloader) ResourceOwner(_ context.Context, ownerPath string return ownerPath } -const maxMemory = 2 << 20 -const paramFile = "file" +const ( + maxMemory = 2 << 20 + paramFile = "file" +) func UploadHandleFunc(s AssetsService, uploader Uploader) func(http.ResponseWriter, *http.Request) { return func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/api/authz/authorization.go b/internal/api/authz/authorization.go index 2099b3e426..ea20a2438f 100644 --- a/internal/api/authz/authorization.go +++ b/internal/api/authz/authorization.go @@ -4,8 +4,11 @@ import ( "context" "fmt" "reflect" + "slices" "strings" + "github.com/zitadel/logging" + "github.com/zitadel/zitadel/internal/telemetry/tracing" "github.com/zitadel/zitadel/internal/zerrors" ) @@ -16,10 +19,10 @@ const ( // CheckUserAuthorization verifies that: // - the token is active, -// - the organisation (**either** provided by ID or verified domain) exists +// - the organization (**either** provided by ID or verified domain) exists // - the user is permitted to call the requested endpoint (permission option in proto) // it will pass the [CtxData] and permission of the user into the ctx [context.Context] -func CheckUserAuthorization(ctx context.Context, req interface{}, token, orgID, orgDomain string, verifier APITokenVerifier, authConfig Config, requiredAuthOption Option, method string) (ctxSetter func(context.Context) context.Context, err error) { +func CheckUserAuthorization(ctx context.Context, req interface{}, token, orgID, orgDomain string, verifier APITokenVerifier, systemRolePermissionMapping []RoleMapping, rolePermissionMapping []RoleMapping, requiredAuthOption Option, method string) (ctxSetter func(context.Context) context.Context, err error) { ctx, span := tracing.NewServerInterceptorSpan(ctx) defer func() { span.EndWithError(err) }() @@ -30,11 +33,12 @@ func CheckUserAuthorization(ctx context.Context, req interface{}, token, orgID, if requiredAuthOption.Permission == authenticated { return func(parent context.Context) context.Context { + parent = addGetSystemUserRolesToCtx(parent, systemRolePermissionMapping, ctxData) return context.WithValue(parent, dataKey, ctxData) }, nil } - requestedPermissions, allPermissions, err := getUserPermissions(ctx, verifier, requiredAuthOption.Permission, authConfig.RolePermissionMappings, ctxData, ctxData.OrgID) + requestedPermissions, allPermissions, err := getUserPermissions(ctx, verifier, requiredAuthOption.Permission, systemRolePermissionMapping, rolePermissionMapping, ctxData, ctxData.OrgID) if err != nil { return nil, err } @@ -50,6 +54,7 @@ func CheckUserAuthorization(ctx context.Context, req interface{}, token, orgID, parent = context.WithValue(parent, dataKey, ctxData) parent = context.WithValue(parent, allPermissionsKey, allPermissions) parent = context.WithValue(parent, requestPermissionsKey, requestedPermissions) + parent = addGetSystemUserRolesToCtx(parent, systemRolePermissionMapping, ctxData) return parent }, nil } @@ -125,3 +130,43 @@ func GetAllPermissionCtxIDs(perms []string) []string { } return ctxIDs } + +type SystemUserPermissionsDBQuery struct { + MemberType string `json:"member_type"` + AggregateID string `json:"aggregate_id"` + ObjectID string `json:"object_id"` + Permissions []string `json:"permissions"` +} + +func addGetSystemUserRolesToCtx(ctx context.Context, systemUserRoleMap []RoleMapping, ctxData CtxData) context.Context { + if len(ctxData.SystemMemberships) == 0 { + return ctx + } + systemUserPermissions := make([]SystemUserPermissionsDBQuery, len(ctxData.SystemMemberships)) + for i, systemPerm := range ctxData.SystemMemberships { + permissions := make([]string, 0, len(systemPerm.Roles)) + for _, role := range systemPerm.Roles { + permissions = append(permissions, getPermissionsFromRole(systemUserRoleMap, role)...) + } + slices.Sort(permissions) + permissions = slices.Compact(permissions) + + systemUserPermissions[i].MemberType = systemPerm.MemberType.String() + systemUserPermissions[i].AggregateID = systemPerm.AggregateID + systemUserPermissions[i].Permissions = permissions + } + return context.WithValue(ctx, systemUserRolesKey, systemUserPermissions) +} + +func GetSystemUserPermissions(ctx context.Context) []SystemUserPermissionsDBQuery { + getSystemUserRolesFuncValue := ctx.Value(systemUserRolesKey) + if getSystemUserRolesFuncValue == nil { + return nil + } + systemUserRoles, ok := getSystemUserRolesFuncValue.([]SystemUserPermissionsDBQuery) + if !ok { + logging.WithFields("Authz").Error("unable to cast []SystemUserPermissionsDBQuery") + return nil + } + return systemUserRoles +} diff --git a/internal/api/authz/context.go b/internal/api/authz/context.go index ff401f8862..d6528cd017 100644 --- a/internal/api/authz/context.go +++ b/internal/api/authz/context.go @@ -22,6 +22,7 @@ const ( dataKey key = 2 allPermissionsKey key = 3 instanceKey key = 4 + systemUserRolesKey key = 5 ) type CtxData struct { @@ -50,7 +51,8 @@ type Memberships []*Membership type Membership struct { MemberType MemberType AggregateID string - //ObjectID differs from aggregate id if object is sub of an aggregate + InstanceID string + // ObjectID differs from aggregate id if object is sub of an aggregate ObjectID string Roles []string diff --git a/internal/api/authz/permissions.go b/internal/api/authz/permissions.go index e96a7b256b..904fbbc33a 100644 --- a/internal/api/authz/permissions.go +++ b/internal/api/authz/permissions.go @@ -7,8 +7,8 @@ import ( "github.com/zitadel/zitadel/internal/zerrors" ) -func CheckPermission(ctx context.Context, resolver MembershipsResolver, roleMappings []RoleMapping, permission, orgID, resourceID string) (err error) { - requestedPermissions, _, err := getUserPermissions(ctx, resolver, permission, roleMappings, GetCtxData(ctx), orgID) +func CheckPermission(ctx context.Context, resolver MembershipsResolver, systemUserRoleMapping []RoleMapping, roleMappings []RoleMapping, permission, orgID, resourceID string) (err error) { + requestedPermissions, _, err := getUserPermissions(ctx, resolver, permission, systemUserRoleMapping, roleMappings, GetCtxData(ctx), orgID) if err != nil { return err } @@ -22,7 +22,7 @@ func CheckPermission(ctx context.Context, resolver MembershipsResolver, roleMapp // getUserPermissions retrieves the memberships of the authenticated user (on instance and provided organisation level), // and maps them to permissions. It will return the requested permission(s) and all other granted permissions separately. -func getUserPermissions(ctx context.Context, resolver MembershipsResolver, requiredPerm string, roleMappings []RoleMapping, ctxData CtxData, orgID string) (requestedPermissions, allPermissions []string, err error) { +func getUserPermissions(ctx context.Context, resolver MembershipsResolver, requiredPerm string, systemUserRoleMappings []RoleMapping, roleMappings []RoleMapping, ctxData CtxData, orgID string) (requestedPermissions, allPermissions []string, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() @@ -31,7 +31,7 @@ func getUserPermissions(ctx context.Context, resolver MembershipsResolver, requi } if ctxData.SystemMemberships != nil { - requestedPermissions, allPermissions = mapMembershipsToPermissions(requiredPerm, ctxData.SystemMemberships, roleMappings) + requestedPermissions, allPermissions = mapMembershipsToPermissions(requiredPerm, ctxData.SystemMemberships, systemUserRoleMappings) return requestedPermissions, allPermissions, nil } diff --git a/internal/api/authz/permissions_test.go b/internal/api/authz/permissions_test.go index 7919747de6..93243d0c09 100644 --- a/internal/api/authz/permissions_test.go +++ b/internal/api/authz/permissions_test.go @@ -120,7 +120,7 @@ func Test_GetUserPermissions(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - _, perms, err := getUserPermissions(context.Background(), tt.args.membershipsResolver, tt.args.requiredPerm, tt.args.authConfig.RolePermissionMappings, tt.args.ctxData, tt.args.ctxData.OrgID) + _, perms, err := getUserPermissions(context.Background(), tt.args.membershipsResolver, tt.args.requiredPerm, nil, tt.args.authConfig.RolePermissionMappings, tt.args.ctxData, tt.args.ctxData.OrgID) if tt.wantErr && err == nil { t.Errorf("got wrong result, should get err: actual: %v ", err) diff --git a/internal/api/grpc/server/middleware/auth_interceptor.go b/internal/api/grpc/server/middleware/auth_interceptor.go index 6eb326a59a..410b4b8abc 100644 --- a/internal/api/grpc/server/middleware/auth_interceptor.go +++ b/internal/api/grpc/server/middleware/auth_interceptor.go @@ -13,13 +13,13 @@ import ( "github.com/zitadel/zitadel/internal/telemetry/tracing" ) -func AuthorizationInterceptor(verifier authz.APITokenVerifier, authConfig authz.Config) grpc.UnaryServerInterceptor { +func AuthorizationInterceptor(verifier authz.APITokenVerifier, systemUserPermissions authz.Config, authConfig authz.Config) grpc.UnaryServerInterceptor { return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) { - return authorize(ctx, req, info, handler, verifier, authConfig) + return authorize(ctx, req, info, handler, verifier, systemUserPermissions, authConfig) } } -func authorize(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler, verifier authz.APITokenVerifier, authConfig authz.Config) (_ interface{}, err error) { +func authorize(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler, verifier authz.APITokenVerifier, systemUserPermissions authz.Config, authConfig authz.Config) (_ interface{}, err error) { authOpt, needsToken := verifier.CheckAuthMethod(info.FullMethod) if !needsToken { return handler(ctx, req) @@ -34,7 +34,7 @@ func authorize(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, } orgID, orgDomain := orgIDAndDomainFromRequest(authCtx, req) - ctxSetter, err := authz.CheckUserAuthorization(authCtx, req, authToken, orgID, orgDomain, verifier, authConfig, authOpt, info.FullMethod) + ctxSetter, err := authz.CheckUserAuthorization(authCtx, req, authToken, orgID, orgDomain, verifier, systemUserPermissions.RolePermissionMappings, authConfig.RolePermissionMappings, authOpt, info.FullMethod) if err != nil { return nil, err } diff --git a/internal/api/grpc/server/middleware/auth_interceptor_test.go b/internal/api/grpc/server/middleware/auth_interceptor_test.go index 3551d3e419..e098189445 100644 --- a/internal/api/grpc/server/middleware/auth_interceptor_test.go +++ b/internal/api/grpc/server/middleware/auth_interceptor_test.go @@ -20,6 +20,7 @@ type authzRepoMock struct{} func (v *authzRepoMock) VerifyAccessToken(ctx context.Context, token, clientID, projectID string) (string, string, string, string, string, error) { return "", "", "", "", "", nil } + func (v *authzRepoMock) SearchMyMemberships(ctx context.Context, orgID string, _ bool) ([]*authz.Membership, error) { return authz.Memberships{{ MemberType: authz.MemberTypeOrganization, @@ -31,9 +32,11 @@ func (v *authzRepoMock) SearchMyMemberships(ctx context.Context, orgID string, _ func (v *authzRepoMock) ProjectIDAndOriginsByClientID(ctx context.Context, clientID string) (string, []string, error) { return "", nil, nil } + func (v *authzRepoMock) ExistsOrg(ctx context.Context, orgID, domain string) (string, error) { return orgID, nil } + func (v *authzRepoMock) VerifierClientID(ctx context.Context, appName string) (string, string, error) { return "", "", nil } @@ -252,7 +255,7 @@ func Test_authorize(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := authorize(tt.args.ctx, tt.args.req, tt.args.info, tt.args.handler, tt.args.verifier(), tt.args.authConfig) + got, err := authorize(tt.args.ctx, tt.args.req, tt.args.info, tt.args.handler, tt.args.verifier(), tt.args.authConfig, tt.args.authConfig) if (err != nil) != tt.res.wantErr { t.Errorf("authorize() error = %v, wantErr %v", err, tt.res.wantErr) return diff --git a/internal/api/grpc/server/server.go b/internal/api/grpc/server/server.go index 27b921b7d5..b686d3add9 100644 --- a/internal/api/grpc/server/server.go +++ b/internal/api/grpc/server/server.go @@ -36,6 +36,7 @@ type WithGatewayPrefix interface { func CreateServer( verifier authz.APITokenVerifier, + systemAuthz authz.Config, authConfig authz.Config, queries *query.Queries, externalDomain string, @@ -53,7 +54,7 @@ func CreateServer( middleware.AccessStorageInterceptor(accessSvc), middleware.ErrorHandler(), middleware.LimitsInterceptor(system_pb.SystemService_ServiceDesc.ServiceName), - middleware.AuthorizationInterceptor(verifier, authConfig), + middleware.AuthorizationInterceptor(verifier, systemAuthz, authConfig), middleware.TranslationHandler(), middleware.QuotaExhaustedInterceptor(accessSvc, system_pb.SystemService_ServiceDesc.ServiceName), middleware.ExecutionHandler(queries), diff --git a/internal/api/grpc/user/v2/integration_test/query_test.go b/internal/api/grpc/user/v2/integration_test/query_test.go index 554de2b69a..15dc959151 100644 --- a/internal/api/grpc/user/v2/integration_test/query_test.go +++ b/internal/api/grpc/user/v2/integration_test/query_test.go @@ -415,6 +415,10 @@ func createUsers(ctx context.Context, orgID string, count int, passwordChangeReq func createUser(ctx context.Context, orgID string, passwordChangeRequired bool) userAttr { username := gofakeit.Email() + return createUserWithUserName(ctx, username, orgID, passwordChangeRequired) +} + +func createUserWithUserName(ctx context.Context, username string, orgID string, passwordChangeRequired bool) userAttr { // used as default country prefix phone := "+41" + gofakeit.Phone() resp := Instance.CreateHumanUserVerified(ctx, orgID, username, phone) @@ -1179,6 +1183,97 @@ func TestServer_ListUsers(t *testing.T) { } } +func TestServer_SystemUsers_ListUsers(t *testing.T) { + defer func() { + _, err := Instance.Client.FeatureV2.ResetInstanceFeatures(IamCTX, &feature.ResetInstanceFeaturesRequest{}) + require.NoError(t, err) + }() + + org1 := Instance.CreateOrganization(IamCTX, fmt.Sprintf("ListUsersOrg-%s", gofakeit.AppName()), gofakeit.Email()) + org2 := Instance.CreateOrganization(IamCTX, fmt.Sprintf("ListUsersOrg-%s", gofakeit.AppName()), "org2@zitadel.com") + org3 := Instance.CreateOrganization(IamCTX, fmt.Sprintf("ListUsersOrg-%s", gofakeit.AppName()), gofakeit.Email()) + _ = createUserWithUserName(IamCTX, "Test_SystemUsers_ListUser1@zitadel.com", org1.OrganizationId, false) + _ = createUserWithUserName(IamCTX, "Test_SystemUsers_ListUser2@zitadel.com", org2.OrganizationId, false) + _ = createUserWithUserName(IamCTX, "Test_SystemUsers_ListUser3@zitadel.com", org3.OrganizationId, false) + + tests := []struct { + name string + ctx context.Context + req *user.ListUsersRequest + expectedFoundUsernames []string + checkNumberOfUsersReturned bool + }{ + { + name: "list users with neccessary permissions", + ctx: SystemCTX, + req: &user.ListUsersRequest{}, + // the number of users returned will vary from test run to test run, + // so just check the system user gets back users from different orgs whcih it is not a memeber of + checkNumberOfUsersReturned: false, + expectedFoundUsernames: []string{"Test_SystemUsers_ListUser1@zitadel.com", "Test_SystemUsers_ListUser2@zitadel.com", "Test_SystemUsers_ListUser3@zitadel.com"}, + }, + { + name: "list users without neccessary permissions", + ctx: SystemUserWithNoPermissionsCTX, + req: &user.ListUsersRequest{}, + // check no users returned + checkNumberOfUsersReturned: true, + }, + { + name: "list users with neccessary permissions specifying org", + req: &user.ListUsersRequest{ + Queries: []*user.SearchQuery{OrganizationIdQuery(org2.OrganizationId)}, + }, + ctx: SystemCTX, + expectedFoundUsernames: []string{"Test_SystemUsers_ListUser2@zitadel.com", "org2@zitadel.com"}, + checkNumberOfUsersReturned: true, + }, + { + name: "list users without neccessary permissions specifying org", + req: &user.ListUsersRequest{ + Queries: []*user.SearchQuery{OrganizationIdQuery(org2.OrganizationId)}, + }, + ctx: SystemUserWithNoPermissionsCTX, + // check no users returned + checkNumberOfUsersReturned: true, + }, + } + + for _, f := range permissionCheckV2Settings { + f := f + for _, tt := range tests { + t.Run(f.TestNamePrependString+tt.name, func(t *testing.T) { + setPermissionCheckV2Flag(t, f.SetFlag) + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(tt.ctx, 1*time.Minute) + require.EventuallyWithT(t, func(ttt *assert.CollectT) { + got, err := Client.ListUsers(tt.ctx, tt.req) + require.NoError(ttt, err) + + if tt.checkNumberOfUsersReturned { + require.Equal(t, len(tt.expectedFoundUsernames), len(got.Result)) + } + + if tt.expectedFoundUsernames != nil { + for _, user := range got.Result { + for i, username := range tt.expectedFoundUsernames { + if username == user.Username { + tt.expectedFoundUsernames = tt.expectedFoundUsernames[i+1:] + break + } + } + if len(tt.expectedFoundUsernames) == 0 { + return + } + } + require.FailNow(t, "unable to find all users with specified usernames") + } + }, retryDuration, tick, "timeout waiting for expected user result") + }) + } + } +} + func InUserIDsQuery(ids []string) *user.SearchQuery { return &user.SearchQuery{ Query: &user.SearchQuery_InUserIdsQuery{ diff --git a/internal/api/grpc/user/v2/integration_test/user_test.go b/internal/api/grpc/user/v2/integration_test/user_test.go index f39212f7e3..bf396fd25d 100644 --- a/internal/api/grpc/user/v2/integration_test/user_test.go +++ b/internal/api/grpc/user/v2/integration_test/user_test.go @@ -31,12 +31,13 @@ import ( ) var ( - CTX context.Context - IamCTX context.Context - UserCTX context.Context - SystemCTX context.Context - Instance *integration.Instance - Client user.UserServiceClient + CTX context.Context + IamCTX context.Context + UserCTX context.Context + SystemCTX context.Context + SystemUserWithNoPermissionsCTX context.Context + Instance *integration.Instance + Client user.UserServiceClient ) func TestMain(m *testing.M) { @@ -46,6 +47,7 @@ func TestMain(m *testing.M) { Instance = integration.NewInstance(ctx) + SystemUserWithNoPermissionsCTX = integration.WithSystemUserWithNoPermissionsAuthorization(ctx) UserCTX = Instance.WithAuthorization(ctx, integration.UserTypeNoPermission) IamCTX = Instance.WithAuthorization(ctx, integration.UserTypeIAMOwner) SystemCTX = integration.WithSystemAuthorization(ctx) @@ -1306,7 +1308,6 @@ func TestServer_UpdateHumanUser_Permission(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := Client.UpdateHumanUser(tt.args.ctx, tt.args.req) if tt.wantErr { require.Error(t, err) @@ -3048,7 +3049,6 @@ func TestServer_ListAuthenticationFactors(t *testing.T) { assert.ElementsMatch(t, tt.want.GetResult(), got.GetResult()) }, retryDuration, tick, "timeout waiting for expected auth methods result") - }) } } diff --git a/internal/api/http/middleware/auth_interceptor.go b/internal/api/http/middleware/auth_interceptor.go index 1581d401b4..ae9377b13d 100644 --- a/internal/api/http/middleware/auth_interceptor.go +++ b/internal/api/http/middleware/auth_interceptor.go @@ -14,14 +14,16 @@ import ( ) type AuthInterceptor struct { - verifier authz.APITokenVerifier - authConfig authz.Config + verifier authz.APITokenVerifier + authConfig authz.Config + systemAuthConfig authz.Config } -func AuthorizationInterceptor(verifier authz.APITokenVerifier, authConfig authz.Config) *AuthInterceptor { +func AuthorizationInterceptor(verifier authz.APITokenVerifier, systemAuthConfig authz.Config, authConfig authz.Config) *AuthInterceptor { return &AuthInterceptor{ - verifier: verifier, - authConfig: authConfig, + verifier: verifier, + authConfig: authConfig, + systemAuthConfig: systemAuthConfig, } } @@ -31,7 +33,7 @@ func (a *AuthInterceptor) Handler(next http.Handler) http.Handler { 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) + ctx, err := authorize(r, a.verifier, a.systemAuthConfig, a.authConfig) if err != nil { http.Error(w, err.Error(), http.StatusUnauthorized) return @@ -44,7 +46,7 @@ func (a *AuthInterceptor) HandlerFunc(next http.Handler) http.HandlerFunc { func (a *AuthInterceptor) HandlerFuncWithError(next HandlerFuncWithError) HandlerFuncWithError { return func(w http.ResponseWriter, r *http.Request) error { - ctx, err := authorize(r, a.verifier, a.authConfig) + ctx, err := authorize(r, a.verifier, a.systemAuthConfig, a.authConfig) if err != nil { return err } @@ -56,7 +58,7 @@ func (a *AuthInterceptor) HandlerFuncWithError(next HandlerFuncWithError) Handle type httpReq struct{} -func authorize(r *http.Request, verifier authz.APITokenVerifier, authConfig authz.Config) (_ context.Context, err error) { +func authorize(r *http.Request, verifier authz.APITokenVerifier, systemAuthConfig authz.Config, authConfig authz.Config) (_ context.Context, err error) { ctx := r.Context() authOpt, needsToken := checkAuthMethod(r, verifier) @@ -71,7 +73,7 @@ func authorize(r *http.Request, verifier authz.APITokenVerifier, authConfig auth return nil, zerrors.ThrowUnauthenticated(nil, "AUT-1179", "auth header missing") } - ctxSetter, err := authz.CheckUserAuthorization(authCtx, &httpReq{}, authToken, http_util.GetOrgID(r), "", verifier, authConfig, authOpt, r.RequestURI) + ctxSetter, err := authz.CheckUserAuthorization(authCtx, &httpReq{}, authToken, http_util.GetOrgID(r), "", verifier, systemAuthConfig.RolePermissionMappings, authConfig.RolePermissionMappings, authOpt, r.RequestURI) if err != nil { return nil, err } diff --git a/internal/id/sonyflake.go b/internal/id/sonyflake.go index 22a3247874..cc7086aa66 100644 --- a/internal/id/sonyflake.go +++ b/internal/id/sonyflake.go @@ -73,7 +73,7 @@ func privateIPv4() (net.IP, error) { } } - //change: use "POD_IP" + // change: use "POD_IP" ip := net.ParseIP(os.Getenv("POD_IP")) if ip == nil { return nil, errors.New("no private ip address") @@ -140,7 +140,7 @@ func machineID() (uint16, error) { } logging.WithFields("errors", strings.Join(errors, ", ")).Panic("none of the enabled methods for identifying the machine succeeded") - //this return will never happen because of panic one line before + // this return will never happen because of panic one line before return 0, nil } diff --git a/internal/integration/config.go b/internal/integration/config.go index 5aea740752..0033e00104 100644 --- a/internal/integration/config.go +++ b/internal/integration/config.go @@ -20,10 +20,8 @@ type Config struct { WebAuthNName string } -var ( - //go:embed config/client.yaml - clientYAML []byte -) +//go:embed config/client.yaml +var clientYAML []byte var ( tmpDir string @@ -49,5 +47,6 @@ func init() { if err := loadedConfig.Log.SetLogger(); err != nil { panic(err) } - SystemToken = systemUserToken() + SystemToken = createSystemUserToken() + SystemUserWithNoPermissionsToken = createSystemUserWithNoPermissionsToken() } diff --git a/internal/integration/config/system-user-with-no-permissions.pem b/internal/integration/config/system-user-with-no-permissions.pem new file mode 100644 index 0000000000..801944ca75 --- /dev/null +++ b/internal/integration/config/system-user-with-no-permissions.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCMxYRfqb4fdnBl +ZmYweqUaZnWQv8RhWDYGifYGen00ozCFT2L6gGov4YCxRVe+l3aFQ79j5SJb1C+v +H68DJkyCTrhDpATqdjVuCu7CEEI//16Ivfmj3gbNdsp0IcDKVIAF0bN9kve5ofRX +CgU6DIx8GjLsXSooSniZnJ4d/Rnt69mpSsPkykUs3RpG2NSOn3WLAoVKh1q/kqeV +qf8eQ+KzuyD/R9QNAPiyB+ivAuOtVuvmIqojQYK5o8veTg/waBxdmzkim7eg8J7B +VDSjBeHagS5K9IJr/Q2VeO0rZOOeJfLlH9xlSrDvc3AIS/3HtkqI268kNkvpGz0I +sg61pUQtAgMBAAECggEAFzZrv1WPaQNAAex6fdR/fKS4Dqwcjxu7XuUpeUSB+GfP +dLAUR2/c8rPJ45FmaGJz9AIpoWiTe5Z33XYJRyjt1U/zQQ4fFGV1JoXtfHkvX3u1 +5DEFZQDT2NYViMRXFNYNvUfow9Rz/nuG/cJEfd+7W6x7SLANJ1MuY1Ao35OQjsOG +ftTtmEUppEIXyWL0PCeHQc83z8aJrP+p4hpjJOW2mui0NR2Hk456DGYXg8I8fcQD +ar7Ar7/A6thR0OmwG7tkkLjRiCjGwnkr19hCNLz+QAWB2o284T12zZueOqRuYQzu +KwNBZKJlClsPkhdZSPLL4RMFP6hJjKoP5mY0Zdzh8QKBgQDEPrM70aZQiweXHqoE +/vZry7tphGycoEAf6nwBBrZaRPpJdnEA61LBlJFv7C3s59uy6L7nHssTyVUJha9i +zFCWRQ0mHNrwxF5Ybd5p//hgblt3X53IV6vZBFF1+OrwRS/AKki3GynDc/oI++hu +PGHWmUF6lIi3uzWwOTqk6EGovQKBgQC3oqpUlpJ78e0zPjIr9ov61TtnPzAa883D +LL7fuNYP9zxIMoFZw++2bZfT5tbINflQdZnVVDNs5KiwtEu3oZJrsqXpQmzCl3j2 +KA9FTdVJQXc2lU90uYb76c5JZPownojbXFFOPQokBqfsYLSdfvNVHSQGjZ3C90wL +YZC0vA9YMQKBgQDCKSraD2YWoEeFO+CJityx8GNfVZbELETljvDbbxGyJDbhwh6y +AyHgxyZR7wHNN+UFkQN31d6kl/jbr/nDrVQ6KN2GjNwNhKu3oBSDGa9bcTRr2h1Y +32z2DTCvoPSJflptLSi+iVB7wd5rTxk7H+DJGt5O8nCGH+JRlX2xNN3pnQKBgDdA +u21eLM8cWNmNQj1WHoInfIsxSQEjEGtEYF4iWE5PfpTelWrz+IF0cjVxBHkTPGPI +LrQwdJS0LEmWxh2HgO3kv+TydpUKTHwMS6P3qlAzYXJL9K9TT1km3UnaFylf2h/e +pBwdY5q5YfdOlam50+9tKDTMkYZjMD9QaODooNlRAoGAOWow99WCATFtRrG+mGyl +UpwApgkZKT0nhkXUnLdNoQVeP0WHeQBSoOA24YnGBntvG/98Uj2rOwdCAYzTGepz +91bNqscrSOPdD3VN85GEl2DQKtxsRCKCdPKmYkvC/WMGhuzXSIp2U+ePgqEjEQO2 +Sn4xXZ1zwl+4cYHmDvzEQnA= +-----END PRIVATE KEY----- diff --git a/internal/integration/config/zitadel.yaml b/internal/integration/config/zitadel.yaml index e2642d9b8f..bb8d86376d 100644 --- a/internal/integration/config/zitadel.yaml +++ b/internal/integration/config/zitadel.yaml @@ -82,6 +82,13 @@ SystemAPIUsers: - "ORG_OWNER" - cypress: KeyData: "LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQklqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FROEFNSUlCQ2dLQ0FRRUF6aStGRlNKTDdmNXl3NEtUd3pnTQpQMzRlUEd5Y20vTStrVDBNN1Y0Q2d4NVYzRWFESXZUUUtUTGZCYUVCNDV6YjlMdGpJWHpEdzByWFJvUzJoTzZ0CmgrQ1lRQ3ozS0N2aDA5QzBJenhaaUIySVMzSC9hVCs1Qng5RUZZK3ZuQWtaamNjYnlHNVlOUnZtdE9sbnZJZUkKSDdxWjB0RXdrUGZGNUdFWk5QSlB0bXkzVUdWN2lvZmRWUVMxeFJqNzMrYU13NXJ2SDREOElkeWlBQzNWZWtJYgpwdDBWajBTVVgzRHdLdG9nMzM3QnpUaVBrM2FYUkYwc2JGaFFvcWRKUkk4TnFnWmpDd2pxOXlmSTV0eXhZc3duCitKR3pIR2RIdlczaWRPRGxtd0V0NUsycGFzaVJJV0syT0dmcSt3MEVjbHRRSGFidXFFUGdabG1oQ2tSZE5maXgKQndJREFRQUIKLS0tLS1FTkQgUFVCTElDIEtFWS0tLS0tCg==" + - system-user-with-no-permissions: + KeyData: "LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQklqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FROEFNSUlCQ2dLQ0FRRUFqTVdFWDZtK0gzWndaV1ptTUhxbApHbVoxa0wvRVlWZzJCb24yQm5wOU5LTXdoVTlpK29CcUwrR0FzVVZYdnBkMmhVTy9ZK1VpVzlRdnJ4K3ZBeVpNCmdrNjRRNlFFNm5ZMWJncnV3aEJDUC85ZWlMMzVvOTRHelhiS2RDSEF5bFNBQmRHemZaTDN1YUgwVndvRk9neU0KZkJveTdGMHFLRXA0bVp5ZUhmMFo3ZXZacVVyRDVNcEZMTjBhUnRqVWpwOTFpd0tGU29kYXY1S25sYW4vSGtQaQpzN3NnLzBmVURRRDRzZ2ZvcndManJWYnI1aUtxSTBHQ3VhUEwzazRQOEdnY1haczVJcHUzb1BDZXdWUTBvd1hoCjJvRXVTdlNDYS8wTmxYanRLMlRqbmlYeTVSL2NaVXF3NzNOd0NFdjl4N1pLaU51dkpEWkw2UnM5Q0xJT3RhVkUKTFFJREFRQUIKLS0tLS1FTkQgUFVCTElDIEtFWS0tLS0tCg==" + Memberships: + # MemberType System allows the user to access all APIs for all instances or organizations + - MemberType: IAM + Roles: + - "NO_ROLES" InitProjections: Enabled: true diff --git a/internal/integration/system.go b/internal/integration/system.go index a9673a40ae..badc3db355 100644 --- a/internal/integration/system.go +++ b/internal/integration/system.go @@ -17,13 +17,16 @@ import ( var ( //go:embed config/system-user-key.pem systemUserKey []byte + //go:embed config/system-user-with-no-permissions.pem + systemUserWithNoPermissions []byte ) var ( // SystemClient creates a system connection once and reuses it on every use. // Each client call automatically gets the authorization context for the system user. - SystemClient = sync.OnceValue[system.SystemServiceClient](systemClient) - SystemToken string + SystemClient = sync.OnceValue[system.SystemServiceClient](systemClient) + SystemToken string + SystemUserWithNoPermissionsToken string ) func systemClient() system.SystemServiceClient { @@ -40,7 +43,7 @@ func systemClient() system.SystemServiceClient { return system.NewSystemServiceClient(cc) } -func systemUserToken() string { +func createSystemUserToken() string { const ISSUER = "tester" audience := http_util.BuildOrigin(loadedConfig.Host(), loadedConfig.Secure) signer, err := client.NewSignerFromPrivateKeyByte(systemUserKey, "") @@ -54,6 +57,24 @@ func systemUserToken() string { return token } +func createSystemUserWithNoPermissionsToken() string { + const ISSUER = "system-user-with-no-permissions" + audience := http_util.BuildOrigin(loadedConfig.Host(), loadedConfig.Secure) + signer, err := client.NewSignerFromPrivateKeyByte(systemUserWithNoPermissions, "") + if err != nil { + panic(err) + } + token, err := client.SignedJWTProfileAssertion(ISSUER, []string{audience}, time.Hour, signer) + if err != nil { + panic(err) + } + return token +} + func WithSystemAuthorization(ctx context.Context) context.Context { return WithAuthorizationToken(ctx, SystemToken) } + +func WithSystemUserWithNoPermissionsAuthorization(ctx context.Context) context.Context { + return WithAuthorizationToken(ctx, SystemUserWithNoPermissionsToken) +} diff --git a/internal/query/permission.go b/internal/query/permission.go index aeda33e541..c52b491144 100644 --- a/internal/query/permission.go +++ b/internal/query/permission.go @@ -2,51 +2,74 @@ package query import ( "context" + "encoding/json" "fmt" sq "github.com/Masterminds/squirrel" "github.com/zitadel/logging" "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/zerrors" ) const ( - // eventstore.permitted_orgs(instanceid text, userid text, perm text, filter_orgs text) - wherePermittedOrgsClause = "%s = ANY(eventstore.permitted_orgs(?, ?, ?, ?))" + // eventstore.permitted_orgs(instanceid text, userid text, system_user_perms JSONB, perm text filter_orgs text) + wherePermittedOrgsClause = "%s = ANY(eventstore.permitted_orgs(?, ?, ?, ?, ?))" wherePermittedOrgsOrCurrentUserClause = "(" + wherePermittedOrgsClause + " OR %s = ?" + ")" ) // wherePermittedOrgs sets a `WHERE` clause to the query that filters the orgs // for which the authenticated user has the requested permission for. // The user ID is taken from the context. -// // The `orgIDColumn` specifies the table column to which this filter must be applied, // and is typically the `resource_owner` column in ZITADEL. // We use full identifiers in the query builder so this function should be // called with something like `UserResourceOwnerCol.identifier()` for example. -func wherePermittedOrgs(ctx context.Context, query sq.SelectBuilder, filterOrgIds, orgIDColumn, permission string) sq.SelectBuilder { - userID := authz.GetCtxData(ctx).UserID - logging.WithFields("permission_check_v2_flag", authz.GetFeatures(ctx).PermissionCheckV2, "org_id_column", orgIDColumn, "permission", permission, "user_id", userID).Debug("permitted orgs check used") +// func wherePermittedOrgs(ctx context.Context, query sq.SelectBuilder, filterOrgIds, orgIDColumn, permission string) (sq.SelectBuilder, error) { +// userID := authz.GetCtxData(ctx).UserID +// logging.WithFields("permission_check_v2_flag", authz.GetFeatures(ctx).PermissionCheckV2, "org_id_column", orgIDColumn, "permission", permission, "user_id", userID).Debug("permitted orgs check used") - return query.Where( - fmt.Sprintf(wherePermittedOrgsClause, orgIDColumn), - authz.GetInstance(ctx).InstanceID(), - userID, - permission, - filterOrgIds, - ) -} +// systemUserPermissions := authz.GetSystemUserPermissions(ctx) +// var systemUserPermissionsJson []byte +// if systemUserPermissions != nil { +// var err error +// systemUserPermissionsJson, err = json.Marshal(systemUserPermissions) +// if err != nil { +// return query, err +// } +// } -func wherePermittedOrgsOrCurrentUser(ctx context.Context, query sq.SelectBuilder, filterOrgIds, orgIDColumn, userIdColum, permission string) sq.SelectBuilder { +// return query.Where( +// fmt.Sprintf(wherePermittedOrgsClause, orgIDColumn), +// authz.GetInstance(ctx).InstanceID(), +// userID, +// systemUserPermissionsJson, +// permission, +// filterOrgIds, +// ), nil +// } + +func wherePermittedOrgsOrCurrentUser(ctx context.Context, query sq.SelectBuilder, filterOrgIds, orgIDColumn, userIdColum, permission string) (sq.SelectBuilder, error) { userID := authz.GetCtxData(ctx).UserID logging.WithFields("permission_check_v2_flag", authz.GetFeatures(ctx).PermissionCheckV2, "org_id_column", orgIDColumn, "user_id_colum", userIdColum, "permission", permission, "user_id", userID).Debug("permitted orgs check used") + systemUserPermissions := authz.GetSystemUserPermissions(ctx) + var systemUserPermissionsJson []byte + if systemUserPermissions != nil { + var err error + systemUserPermissionsJson, err = json.Marshal(systemUserPermissions) + if err != nil { + return query, zerrors.ThrowInternal(err, "AUTHZ-HS4us", "Errors.Internal") + } + } + return query.Where( fmt.Sprintf(wherePermittedOrgsOrCurrentUserClause, orgIDColumn, userIdColum), authz.GetInstance(ctx).InstanceID(), userID, + systemUserPermissionsJson, permission, filterOrgIds, userID, - ) + ), nil } diff --git a/internal/query/user.go b/internal/query/user.go index 233faf4aa9..62de234b39 100644 --- a/internal/query/user.go +++ b/internal/query/user.go @@ -654,7 +654,10 @@ func (q *Queries) searchUsers(ctx context.Context, queries *UserSearchQueries, f UserInstanceIDCol.identifier(): authz.GetInstance(ctx).InstanceID(), }) if permissionCheckV2 { - query = wherePermittedOrgsOrCurrentUser(ctx, query, filterOrgIds, UserResourceOwnerCol.identifier(), UserIDCol.identifier(), domain.PermissionUserRead) + query, err = wherePermittedOrgsOrCurrentUser(ctx, query, filterOrgIds, UserResourceOwnerCol.identifier(), UserIDCol.identifier(), domain.PermissionUserRead) + if err != nil { + return nil, zerrors.ThrowInternal(err, "AUTHZ-HS4us", "Errors.Internal") + } } stmt, args, err := query.ToSql() @@ -736,15 +739,19 @@ func (r *UserSearchQueries) AppendMyResourceOwnerQuery(orgID string) error { func NewUserOrSearchQuery(values []SearchQuery) (SearchQuery, error) { return NewOrQuery(values...) } + func NewUserAndSearchQuery(values []SearchQuery) (SearchQuery, error) { return NewAndQuery(values...) } + func NewUserNotSearchQuery(value SearchQuery) (SearchQuery, error) { return NewNotQuery(value) } + func NewUserInUserIdsSearchQuery(values []string) (SearchQuery, error) { return NewInTextQuery(UserIDCol, values) } + func NewUserInUserEmailsSearchQuery(values []string) (SearchQuery, error) { return NewInTextQuery(HumanEmailCol, values) } @@ -806,7 +813,7 @@ func NewUserLoginNamesSearchQuery(value string) (SearchQuery, error) { } func NewUserLoginNameExistsQuery(value string, comparison TextComparison) (SearchQuery, error) { - //linking queries for the subselect + // linking queries for the subselect instanceQuery, err := NewColumnComparisonQuery(LoginNameInstanceIDCol, UserInstanceIDCol, ColumnEquals) if err != nil { return nil, err @@ -815,12 +822,12 @@ func NewUserLoginNameExistsQuery(value string, comparison TextComparison) (Searc if err != nil { return nil, err } - //text query to select data from the linked sub select + // text query to select data from the linked sub select loginNameQuery, err := NewTextQuery(LoginNameNameCol, value, comparison) if err != nil { return nil, err } - //full definition of the sub select + // full definition of the sub select subSelect, err := NewSubSelect(LoginNameUserIDCol, []SearchQuery{instanceQuery, userIDQuery, loginNameQuery}) if err != nil { return nil, err