From 0e181b218cf362a70a73532922492d39804cf682 Mon Sep 17 00:00:00 2001 From: Livio Spring Date: Tue, 12 Mar 2024 14:50:13 +0100 Subject: [PATCH] feat: implement user schema management (#7416) This PR adds the functionality to manage user schemas through the new user schema service. It includes the possibility to create a basic JSON schema and also provides a way on defining permissions (read, write) for owner and self context with an annotation. Further annotations for OIDC claims and SAML attribute mappings will follow. A guide on how to create a schema and assign permissions has been started. It will be extended though out the process of implementing the schema and users based on those. Note: This feature is in an early stage and therefore not enabled by default. To test it out, please enable the UserSchema feature flag on your instance / system though the feature service. --- cmd/defaults.yaml | 4 +- cmd/start/config_test.go | 2 + cmd/start/start.go | 4 + .../guides/manage/customize/user-schema.md | 135 +++ docs/sidebars.js | 1 + go.mod | 1 + go.sum | 2 + internal/api/grpc/feature/v2/converter.go | 4 + .../api/grpc/feature/v2/converter_test.go | 20 + .../feature/v2/feature_integration_test.go | 15 + .../api/grpc/user/schema/v3alpha/schema.go | 150 +++ .../schema/v3alpha/schema_integration_test.go | 812 ++++++++++++++++ .../api/grpc/user/schema/v3alpha/server.go | 51 + internal/command/instance_features.go | 4 +- internal/command/instance_features_model.go | 5 + internal/command/instance_features_test.go | 23 + internal/command/system_features.go | 4 +- internal/command/system_features_model.go | 5 + internal/command/system_features_test.go | 28 + internal/command/user_schema.go | 180 ++++ internal/command/user_schema_model.go | 112 +++ internal/command/user_schema_test.go | 912 ++++++++++++++++++ internal/domain/schema/permission.go | 120 +++ .../domain/schema/permission.schema.v1.json | 28 + internal/domain/schema/permission_test.go | 253 +++++ internal/domain/schema/schema.go | 41 + internal/domain/schema/zitadel.schema.v1.json | 13 + internal/domain/user_schema.go | 26 + internal/feature/feature.go | 2 + internal/feature/key_enumer.go | 12 +- internal/integration/client.go | 70 +- internal/query/instance_features.go | 1 + internal/query/instance_features_model.go | 5 + internal/query/instance_features_test.go | 24 + .../query/projection/instance_features.go | 4 + internal/query/projection/system_features.go | 4 + internal/query/system_features.go | 1 + internal/query/system_features_model.go | 3 + internal/query/system_features_test.go | 24 + .../feature/feature_v2/eventstore.go | 2 + .../repository/feature/feature_v2/feature.go | 2 + internal/repository/user/schema/aggregate.go | 25 + internal/repository/user/schema/eventstore.go | 11 + internal/repository/user/schema/schema.go | 233 +++++ internal/static/i18n/bg.yaml | 17 + internal/static/i18n/cs.yaml | 17 + internal/static/i18n/de.yaml | 17 + internal/static/i18n/en.yaml | 17 + internal/static/i18n/es.yaml | 17 + internal/static/i18n/fr.yaml | 17 + internal/static/i18n/it.yaml | 18 +- internal/static/i18n/ja.yaml | 17 + internal/static/i18n/mk.yaml | 17 + internal/static/i18n/nl.yaml | 17 + internal/static/i18n/pl.yaml | 17 + internal/static/i18n/pt.yaml | 17 + internal/static/i18n/ru.yaml | 17 + internal/static/i18n/zh.yaml | 17 + proto/zitadel/feature/v2beta/instance.proto | 14 +- proto/zitadel/feature/v2beta/system.proto | 14 + .../schema/v3alpha/user_schema_service.proto | 4 +- 61 files changed, 3614 insertions(+), 35 deletions(-) create mode 100644 docs/docs/guides/manage/customize/user-schema.md create mode 100644 internal/api/grpc/user/schema/v3alpha/schema.go create mode 100644 internal/api/grpc/user/schema/v3alpha/schema_integration_test.go create mode 100644 internal/api/grpc/user/schema/v3alpha/server.go create mode 100644 internal/command/user_schema.go create mode 100644 internal/command/user_schema_model.go create mode 100644 internal/command/user_schema_test.go create mode 100644 internal/domain/schema/permission.go create mode 100644 internal/domain/schema/permission.schema.v1.json create mode 100644 internal/domain/schema/permission_test.go create mode 100644 internal/domain/schema/schema.go create mode 100644 internal/domain/schema/zitadel.schema.v1.json create mode 100644 internal/domain/user_schema.go create mode 100644 internal/repository/user/schema/aggregate.go create mode 100644 internal/repository/user/schema/eventstore.go create mode 100644 internal/repository/user/schema/schema.go diff --git a/cmd/defaults.yaml b/cmd/defaults.yaml index 781f13d501..52b6666435 100644 --- a/cmd/defaults.yaml +++ b/cmd/defaults.yaml @@ -832,7 +832,7 @@ DefaultInstance: Greeting: Hello {{.DisplayName}}, Text: The password of your user has changed. If this change was not done by you, please be advised to immediately reset your password. ButtonText: Login - + # Once a feature is set on the instance (true or false), system level feature settings # will be ignored until instance level features are reset. Features: @@ -1016,6 +1016,8 @@ InternalAuthZ: - "execution.read" - "execution.write" - "execution.delete" + - "userschema.write" + - "userschema.delete" - Role: "IAM_OWNER_VIEWER" Permissions: - "iam.read" diff --git a/cmd/start/config_test.go b/cmd/start/config_test.go index a7802cf582..9319e574df 100644 --- a/cmd/start/config_test.go +++ b/cmd/start/config_test.go @@ -75,6 +75,7 @@ DefaultInstance: LoginDefaultOrg: true LegacyIntrospection: true TriggerIntrospectionProjections: true + UserSchema: true Log: Level: info Actions: @@ -86,6 +87,7 @@ Actions: LoginDefaultOrg: gu.Ptr(true), LegacyIntrospection: gu.Ptr(true), TriggerIntrospectionProjections: gu.Ptr(true), + UserSchema: gu.Ptr(true), }) }, }, { diff --git a/cmd/start/start.go b/cmd/start/start.go index a19bec3044..991c7e816b 100644 --- a/cmd/start/start.go +++ b/cmd/start/start.go @@ -44,6 +44,7 @@ import ( "github.com/zitadel/zitadel/internal/api/grpc/session/v2" "github.com/zitadel/zitadel/internal/api/grpc/settings/v2" "github.com/zitadel/zitadel/internal/api/grpc/system" + user_schema_v3_alpha "github.com/zitadel/zitadel/internal/api/grpc/user/schema/v3alpha" user_v2 "github.com/zitadel/zitadel/internal/api/grpc/user/v2" http_util "github.com/zitadel/zitadel/internal/api/http" "github.com/zitadel/zitadel/internal/api/http/middleware" @@ -410,6 +411,9 @@ func startAPIs( if err := apis.RegisterService(ctx, execution_v3_alpha.CreateServer(commands, queries, domain.AllFunctions, apis.ListGrpcMethods, apis.ListGrpcServices)); err != nil { return nil, err } + if err := apis.RegisterService(ctx, user_schema_v3_alpha.CreateServer(commands, queries)); err != nil { + return nil, err + } instanceInterceptor := middleware.InstanceInterceptor(queries, config.HTTP1HostHeader, 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)) diff --git a/docs/docs/guides/manage/customize/user-schema.md b/docs/docs/guides/manage/customize/user-schema.md new file mode 100644 index 0000000000..ceab18873e --- /dev/null +++ b/docs/docs/guides/manage/customize/user-schema.md @@ -0,0 +1,135 @@ +--- +title: User Schema +--- + +ZITADEL allows you to define schemas for users, based on the [JSON Schema Standard](https://json-schema.org/). +This gives you the possibility to define your own data models for your users, validate them based on these definitions +and making sure who has access or manipulate information of the user. + +By defining multiple schemas, you can even differentiate between different personas of your organization or application +and restrictions, resp. requirements for them to authenticate. + +For example, you could have separate schemas for your employees and your customers. While you might want to make sure that +certain data like given name and family name are required for employees, they might be optional for the latter. +Or you might want to disable username password authentication for your admins and only allow phishing resistant methods like passkeys, +but still allow it for your customers. + +:::info +Please be aware that User Schema is in a [preview stage](/support/software-release-cycles-support#preview) not feature complete +and therefore not generally available. + +Do not use it for production yet. To test it out, you need to enable the `UserSchema` [feature flag](/apis/resources/feature_service_v2/feature-service). +::: + +## Create your first schema + +Let's create the first very simple schema `user`, which defines a `givenName` and `familyName` for a user and allows them to +authenticate with username and password. + +We can do so by calling the [create user schema endpoint](/docs/apis/resources/user_schema_service_v3/user-schema-service-create-user-schema) +with the following data. Make sure to provide an access_token with an IAM_OWNER role. + +```bash +curl -X POST "https://$CUSTOM-DOMAIN/v3alpha/user_schemas" \ +-H 'Content-Type: application/json' \ +-H 'Accept: application/json' \ +-H "Authorization: Bearer $ACCESS_TOKEN" \ +--data-raw '{ + "type": "user", + "schema": { + "$schema": "urn:zitadel:schema:v1", + "type": "object", + "properties": { + "givenName": { + "type": "string" + }, + "familyName": { + "type": "string" + } + } + }, + "possibleAuthenticators": [ + "AUTHENTICATOR_TYPE_USERNAME", + "AUTHENTICATOR_TYPE_PASSWORD" + ] +}' +``` + +This will return something similar to: +```json +{ + "id": "257199613398745508", + "details": { + "sequence": "2", + "change_date": "2024-03-07T08:08:35.963956Z", + "resource_owner": "253750309325636004" + } +} +``` + +So you successfully create a schema and could use that to manage your users based on that. +But let's first checkout some possibilities ZITADEL offers. + +## Assign Permissions + +In the first step we've created a very simple `user` schema with only `givenName` and `familyName`. +This allows any user with the permission to edit the user's data to change these values. +Let's now update the schema and add some more properties and restrict who's able to retrieve and change data. + +By setting `urn:zitadel:schema:permission` to fields, we can define the permissions for that field of different user roles / context. + +For example by adding it to the `givenName` and `familyName` we can keep the state from before, where any `owner` (e.g. ORG_OWNER) +as well as the user themselves (`self`) are allowed to read (`r`) and write (`w`) the data. + +Let's now assume our service provides some profile information of the user on a dedicated page. +Since we do not want the user to be able to change that value, we set the permission of `self` to `r`, meaning they will be able +to see the `profileUri` value, but cannot update it. + +Maybe we also have some `customerId`, which the user should not even know about. We therefore can simply omit the `self` permission +and only set `owner` to `rw`, so admins are able to read and change the id if needed. + +Finally, we call the [update user schema endpoint](/docs/apis/resources/user_schema_service_v3/user-schema-service-update-user-schema) +with the following data. Be sure to provide the id of the previously created schema. + +```bash +curl -X PUT "https://$CUSTOM-DOMAIN/v3alpha/user_schemas/$SCHEMA_ID" \ +-H 'Content-Type: application/json' \ +-H 'Accept: application/json' \ +-H "Authorization: Bearer $ACCESS_TOKEN" \ +--data-raw '{ + "schema": { + "$schema": "urn:zitadel:schema:v1", + "type": "object", + "properties": { + "givenName": { + "type": "string", + "urn:zitadel:schema:permission": { + "owner": "rw", + "self": "rw" + } + }, + "familyName": { + "type": "string", + "urn:zitadel:schema:permission": { + "owner": "rw", + "self": "rw" + } + }, + "profileUri": { + "type": "string", + "format": "uri", + "urn:zitadel:schema:permission": { + "owner": "rw", + "self": "r" + } + }, + "customerId": { + "type": "string", + "urn:zitadel:schema:permission": { + "owner": "rw" + } + } + } + } +} +``` diff --git a/docs/sidebars.js b/docs/sidebars.js index ca80d5cf1b..9686c398a5 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -165,6 +165,7 @@ module.exports = { items: [ "guides/manage/user/reg-create-user", "guides/manage/customize/user-metadata", + "guides/manage/customize/user-schema", ], }, ], diff --git a/go.mod b/go.mod index 6d2fcca4aa..fa7b51de0f 100644 --- a/go.mod +++ b/go.mod @@ -54,6 +54,7 @@ require ( github.com/pquerna/otp v1.4.0 github.com/rakyll/statik v0.1.7 github.com/rs/cors v1.10.1 + github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 github.com/sony/sonyflake v1.2.0 github.com/spf13/cobra v1.8.0 github.com/spf13/viper v1.18.2 diff --git a/go.sum b/go.sum index 7eece0c7a7..2932e4fdf0 100644 --- a/go.sum +++ b/go.sum @@ -694,6 +694,8 @@ github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgY github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= github.com/samuel/go-zookeeper v0.0.0-20190923202752-2cc03de413da/go.mod h1:gi+0XIa01GRL2eRQVjQkKGqKF3SF9vZR/HnPullcV2E= +github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 h1:lZUw3E0/J3roVtGQ+SCrUrg3ON6NgVqpn3+iol9aGu4= +github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY= github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4= diff --git a/internal/api/grpc/feature/v2/converter.go b/internal/api/grpc/feature/v2/converter.go index 46d6f33ade..2ded702f75 100644 --- a/internal/api/grpc/feature/v2/converter.go +++ b/internal/api/grpc/feature/v2/converter.go @@ -13,6 +13,7 @@ func systemFeaturesToCommand(req *feature_pb.SetSystemFeaturesRequest) *command. LoginDefaultOrg: req.LoginDefaultOrg, TriggerIntrospectionProjections: req.OidcTriggerIntrospectionProjections, LegacyIntrospection: req.OidcLegacyIntrospection, + UserSchema: req.UserSchema, } } @@ -22,6 +23,7 @@ func systemFeaturesToPb(f *query.SystemFeatures) *feature_pb.GetSystemFeaturesRe LoginDefaultOrg: featureSourceToFlagPb(&f.LoginDefaultOrg), OidcTriggerIntrospectionProjections: featureSourceToFlagPb(&f.TriggerIntrospectionProjections), OidcLegacyIntrospection: featureSourceToFlagPb(&f.LegacyIntrospection), + UserSchema: featureSourceToFlagPb(&f.UserSchema), } } @@ -30,6 +32,7 @@ func instanceFeaturesToCommand(req *feature_pb.SetInstanceFeaturesRequest) *comm LoginDefaultOrg: req.LoginDefaultOrg, TriggerIntrospectionProjections: req.OidcTriggerIntrospectionProjections, LegacyIntrospection: req.OidcLegacyIntrospection, + UserSchema: req.UserSchema, } } @@ -39,6 +42,7 @@ func instanceFeaturesToPb(f *query.InstanceFeatures) *feature_pb.GetInstanceFeat LoginDefaultOrg: featureSourceToFlagPb(&f.LoginDefaultOrg), OidcTriggerIntrospectionProjections: featureSourceToFlagPb(&f.TriggerIntrospectionProjections), OidcLegacyIntrospection: featureSourceToFlagPb(&f.LegacyIntrospection), + UserSchema: featureSourceToFlagPb(&f.UserSchema), } } diff --git a/internal/api/grpc/feature/v2/converter_test.go b/internal/api/grpc/feature/v2/converter_test.go index 43a1e9c782..3b90ee15ef 100644 --- a/internal/api/grpc/feature/v2/converter_test.go +++ b/internal/api/grpc/feature/v2/converter_test.go @@ -21,11 +21,13 @@ func Test_systemFeaturesToCommand(t *testing.T) { LoginDefaultOrg: gu.Ptr(true), OidcTriggerIntrospectionProjections: gu.Ptr(false), OidcLegacyIntrospection: nil, + UserSchema: gu.Ptr(true), } want := &command.SystemFeatures{ LoginDefaultOrg: gu.Ptr(true), TriggerIntrospectionProjections: gu.Ptr(false), LegacyIntrospection: nil, + UserSchema: gu.Ptr(true), } got := systemFeaturesToCommand(arg) assert.Equal(t, want, got) @@ -50,6 +52,10 @@ func Test_systemFeaturesToPb(t *testing.T) { Level: feature.LevelSystem, Value: true, }, + UserSchema: query.FeatureSource[bool]{ + Level: feature.LevelSystem, + Value: true, + }, } want := &feature_pb.GetSystemFeaturesResponse{ Details: &object.Details{ @@ -69,6 +75,10 @@ func Test_systemFeaturesToPb(t *testing.T) { Enabled: true, Source: feature_pb.Source_SOURCE_SYSTEM, }, + UserSchema: &feature_pb.FeatureFlag{ + Enabled: true, + Source: feature_pb.Source_SOURCE_SYSTEM, + }, } got := systemFeaturesToPb(arg) assert.Equal(t, want, got) @@ -79,11 +89,13 @@ func Test_instanceFeaturesToCommand(t *testing.T) { LoginDefaultOrg: gu.Ptr(true), OidcTriggerIntrospectionProjections: gu.Ptr(false), OidcLegacyIntrospection: nil, + UserSchema: gu.Ptr(true), } want := &command.InstanceFeatures{ LoginDefaultOrg: gu.Ptr(true), TriggerIntrospectionProjections: gu.Ptr(false), LegacyIntrospection: nil, + UserSchema: gu.Ptr(true), } got := instanceFeaturesToCommand(arg) assert.Equal(t, want, got) @@ -108,6 +120,10 @@ func Test_instanceFeaturesToPb(t *testing.T) { Level: feature.LevelInstance, Value: true, }, + UserSchema: query.FeatureSource[bool]{ + Level: feature.LevelInstance, + Value: true, + }, } want := &feature_pb.GetInstanceFeaturesResponse{ Details: &object.Details{ @@ -127,6 +143,10 @@ func Test_instanceFeaturesToPb(t *testing.T) { Enabled: true, Source: feature_pb.Source_SOURCE_INSTANCE, }, + UserSchema: &feature_pb.FeatureFlag{ + Enabled: true, + Source: feature_pb.Source_SOURCE_INSTANCE, + }, } got := instanceFeaturesToPb(arg) assert.Equal(t, want, got) diff --git a/internal/api/grpc/feature/v2/feature_integration_test.go b/internal/api/grpc/feature/v2/feature_integration_test.go index 338f6a70ee..5413936b21 100644 --- a/internal/api/grpc/feature/v2/feature_integration_test.go +++ b/internal/api/grpc/feature/v2/feature_integration_test.go @@ -218,6 +218,7 @@ func TestServer_GetSystemFeatures(t *testing.T) { assertFeatureFlag(t, tt.want.LoginDefaultOrg, got.LoginDefaultOrg) assertFeatureFlag(t, tt.want.OidcTriggerIntrospectionProjections, got.OidcTriggerIntrospectionProjections) assertFeatureFlag(t, tt.want.OidcLegacyIntrospection, got.OidcLegacyIntrospection) + assertFeatureFlag(t, tt.want.UserSchema, got.UserSchema) }) } } @@ -384,6 +385,10 @@ func TestServer_GetInstanceFeatures(t *testing.T) { Enabled: true, Source: feature.Source_SOURCE_SYSTEM, }, + UserSchema: &feature.FeatureFlag{ + Enabled: false, + Source: feature.Source_SOURCE_UNSPECIFIED, + }, }, }, { @@ -392,6 +397,7 @@ func TestServer_GetInstanceFeatures(t *testing.T) { _, err := Client.SetInstanceFeatures(IamCTX, &feature.SetInstanceFeaturesRequest{ LoginDefaultOrg: gu.Ptr(true), OidcTriggerIntrospectionProjections: gu.Ptr(false), + UserSchema: gu.Ptr(true), }) require.NoError(t, err) }, @@ -408,6 +414,10 @@ func TestServer_GetInstanceFeatures(t *testing.T) { Enabled: false, Source: feature.Source_SOURCE_INSTANCE, }, + UserSchema: &feature.FeatureFlag{ + Enabled: true, + Source: feature.Source_SOURCE_INSTANCE, + }, }, }, { @@ -437,6 +447,10 @@ func TestServer_GetInstanceFeatures(t *testing.T) { Enabled: true, Source: feature.Source_SOURCE_SYSTEM, }, + UserSchema: &feature.FeatureFlag{ + Enabled: false, + Source: feature.Source_SOURCE_UNSPECIFIED, + }, }, }, } @@ -459,6 +473,7 @@ func TestServer_GetInstanceFeatures(t *testing.T) { assertFeatureFlag(t, tt.want.LoginDefaultOrg, got.LoginDefaultOrg) assertFeatureFlag(t, tt.want.OidcTriggerIntrospectionProjections, got.OidcTriggerIntrospectionProjections) assertFeatureFlag(t, tt.want.OidcLegacyIntrospection, got.OidcLegacyIntrospection) + assertFeatureFlag(t, tt.want.UserSchema, got.UserSchema) }) } } diff --git a/internal/api/grpc/user/schema/v3alpha/schema.go b/internal/api/grpc/user/schema/v3alpha/schema.go new file mode 100644 index 0000000000..29dd670481 --- /dev/null +++ b/internal/api/grpc/user/schema/v3alpha/schema.go @@ -0,0 +1,150 @@ +package schema + +import ( + "context" + + "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/zerrors" + schema "github.com/zitadel/zitadel/pkg/grpc/user/schema/v3alpha" +) + +func (s *Server) CreateUserSchema(ctx context.Context, req *schema.CreateUserSchemaRequest) (*schema.CreateUserSchemaResponse, error) { + if err := checkUserSchemaEnabled(ctx); err != nil { + return nil, err + } + userSchema, err := createUserSchemaToCommand(req, authz.GetInstance(ctx).InstanceID()) + if err != nil { + return nil, err + } + id, details, err := s.command.CreateUserSchema(ctx, userSchema) + if err != nil { + return nil, err + } + return &schema.CreateUserSchemaResponse{ + Id: id, + Details: object.DomainToDetailsPb(details), + }, nil +} + +func (s *Server) UpdateUserSchema(ctx context.Context, req *schema.UpdateUserSchemaRequest) (*schema.UpdateUserSchemaResponse, error) { + if err := checkUserSchemaEnabled(ctx); err != nil { + return nil, err + } + userSchema, err := updateUserSchemaToCommand(req, authz.GetInstance(ctx).InstanceID()) + if err != nil { + return nil, err + } + details, err := s.command.UpdateUserSchema(ctx, userSchema) + if err != nil { + return nil, err + } + return &schema.UpdateUserSchemaResponse{ + Details: object.DomainToDetailsPb(details), + }, nil +} +func (s *Server) DeactivateUserSchema(ctx context.Context, req *schema.DeactivateUserSchemaRequest) (*schema.DeactivateUserSchemaResponse, error) { + if err := checkUserSchemaEnabled(ctx); err != nil { + return nil, err + } + details, err := s.command.DeactivateUserSchema(ctx, req.GetId(), authz.GetInstance(ctx).InstanceID()) + if err != nil { + return nil, err + } + return &schema.DeactivateUserSchemaResponse{ + Details: object.DomainToDetailsPb(details), + }, nil +} +func (s *Server) ReactivateUserSchema(ctx context.Context, req *schema.ReactivateUserSchemaRequest) (*schema.ReactivateUserSchemaResponse, error) { + if err := checkUserSchemaEnabled(ctx); err != nil { + return nil, err + } + details, err := s.command.ReactivateUserSchema(ctx, req.GetId(), authz.GetInstance(ctx).InstanceID()) + if err != nil { + return nil, err + } + return &schema.ReactivateUserSchemaResponse{ + Details: object.DomainToDetailsPb(details), + }, nil +} +func (s *Server) DeleteUserSchema(ctx context.Context, req *schema.DeleteUserSchemaRequest) (*schema.DeleteUserSchemaResponse, error) { + if err := checkUserSchemaEnabled(ctx); err != nil { + return nil, err + } + details, err := s.command.DeleteUserSchema(ctx, req.GetId(), authz.GetInstance(ctx).InstanceID()) + if err != nil { + return nil, err + } + return &schema.DeleteUserSchemaResponse{ + Details: object.DomainToDetailsPb(details), + }, nil +} + +func checkUserSchemaEnabled(ctx context.Context) error { + if authz.GetInstance(ctx).Features().UserSchema { + return nil + } + return zerrors.ThrowPreconditionFailed(nil, "SCHEMA-SFjk3", "Errors.UserSchema.NotEnabled") +} + +func createUserSchemaToCommand(req *schema.CreateUserSchemaRequest, resourceOwner string) (*command.CreateUserSchema, error) { + schema, err := req.GetSchema().MarshalJSON() + if err != nil { + return nil, err + } + return &command.CreateUserSchema{ + ResourceOwner: resourceOwner, + Type: req.GetType(), + Schema: schema, + PossibleAuthenticators: authenticatorsToDomain(req.GetPossibleAuthenticators()), + }, nil +} + +func updateUserSchemaToCommand(req *schema.UpdateUserSchemaRequest, resourceOwner string) (*command.UpdateUserSchema, error) { + schema, err := req.GetSchema().MarshalJSON() + if err != nil { + return nil, err + } + return &command.UpdateUserSchema{ + ID: req.GetId(), + ResourceOwner: resourceOwner, + Type: req.Type, + Schema: schema, + PossibleAuthenticators: authenticatorsToDomain(req.GetPossibleAuthenticators()), + }, nil +} + +func authenticatorsToDomain(authenticators []schema.AuthenticatorType) []domain.AuthenticatorType { + types := make([]domain.AuthenticatorType, len(authenticators)) + for i, authenticator := range authenticators { + types[i] = authenticatorTypeToDomain(authenticator) + } + return types +} + +func authenticatorTypeToDomain(authenticator schema.AuthenticatorType) domain.AuthenticatorType { + switch authenticator { + case schema.AuthenticatorType_AUTHENTICATOR_TYPE_UNSPECIFIED: + return domain.AuthenticatorTypeUnspecified + case schema.AuthenticatorType_AUTHENTICATOR_TYPE_USERNAME: + return domain.AuthenticatorTypeUsername + case schema.AuthenticatorType_AUTHENTICATOR_TYPE_PASSWORD: + return domain.AuthenticatorTypePassword + case schema.AuthenticatorType_AUTHENTICATOR_TYPE_WEBAUTHN: + return domain.AuthenticatorTypeWebAuthN + case schema.AuthenticatorType_AUTHENTICATOR_TYPE_TOTP: + return domain.AuthenticatorTypeTOTP + case schema.AuthenticatorType_AUTHENTICATOR_TYPE_OTP_EMAIL: + return domain.AuthenticatorTypeOTPEmail + case schema.AuthenticatorType_AUTHENTICATOR_TYPE_OTP_SMS: + return domain.AuthenticatorTypeOTPSMS + case schema.AuthenticatorType_AUTHENTICATOR_TYPE_AUTHENTICATION_KEY: + return domain.AuthenticatorTypeAuthenticationKey + case schema.AuthenticatorType_AUTHENTICATOR_TYPE_IDENTITY_PROVIDER: + return domain.AuthenticatorTypeIdentityProvider + default: + return domain.AuthenticatorTypeUnspecified + } +} diff --git a/internal/api/grpc/user/schema/v3alpha/schema_integration_test.go b/internal/api/grpc/user/schema/v3alpha/schema_integration_test.go new file mode 100644 index 0000000000..177b9c8130 --- /dev/null +++ b/internal/api/grpc/user/schema/v3alpha/schema_integration_test.go @@ -0,0 +1,812 @@ +//go:build integration + +package schema_test + +import ( + "context" + "fmt" + "os" + "testing" + "time" + + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/structpb" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/integration" + feature "github.com/zitadel/zitadel/pkg/grpc/feature/v2beta" + object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta" + schema "github.com/zitadel/zitadel/pkg/grpc/user/schema/v3alpha" +) + +var ( + CTX context.Context + Tester *integration.Tester + Client schema.UserSchemaServiceClient +) + +func TestMain(m *testing.M) { + os.Exit(func() int { + ctx, _, cancel := integration.Contexts(5 * time.Minute) + defer cancel() + + Tester = integration.NewTester(ctx) + defer Tester.Done() + + CTX = Tester.WithAuthorization(ctx, integration.IAMOwner) + Client = Tester.Client.UserSchemaV3 + + return m.Run() + }()) +} + +func ensureFeatureEnabled(t *testing.T) { + f, err := Tester.Client.FeatureV2.GetInstanceFeatures(CTX, &feature.GetInstanceFeaturesRequest{ + Inheritance: true, + }) + require.NoError(t, err) + if f.UserSchema.GetEnabled() { + return + } + _, err = Tester.Client.FeatureV2.SetInstanceFeatures(CTX, &feature.SetInstanceFeaturesRequest{ + UserSchema: gu.Ptr(true), + }) + require.NoError(t, err) + retryDuration := time.Minute + if ctxDeadline, ok := CTX.Deadline(); ok { + retryDuration = time.Until(ctxDeadline) + } + require.EventuallyWithT(t, + func(ttt *assert.CollectT) { + f, err := Tester.Client.FeatureV2.GetInstanceFeatures(CTX, &feature.GetInstanceFeaturesRequest{ + Inheritance: true, + }) + require.NoError(ttt, err) + if f.UserSchema.GetEnabled() { + return + } + }, + retryDuration, + 100*time.Millisecond, + "timed out waiting for ensuring instance feature") +} + +func TestServer_CreateUserSchema(t *testing.T) { + tests := []struct { + name string + ctx context.Context + req *schema.CreateUserSchemaRequest + want *schema.CreateUserSchemaResponse + wantErr bool + }{ + { + name: "missing permission, error", + ctx: Tester.WithAuthorization(context.Background(), integration.OrgOwner), + req: &schema.CreateUserSchemaRequest{ + Type: fmt.Sprint(time.Now().UnixNano() + 1), + }, + wantErr: true, + }, + { + name: "empty type", + ctx: CTX, + req: &schema.CreateUserSchemaRequest{ + Type: "", + }, + wantErr: true, + }, + { + name: "empty schema, error", + ctx: CTX, + req: &schema.CreateUserSchemaRequest{ + Type: fmt.Sprint(time.Now().UnixNano() + 1), + }, + wantErr: true, + }, + { + name: "invalid schema, error", + ctx: CTX, + req: &schema.CreateUserSchemaRequest{ + Type: fmt.Sprint(time.Now().UnixNano() + 1), + DataType: &schema.CreateUserSchemaRequest_Schema{ + Schema: func() *structpb.Struct { + s := new(structpb.Struct) + err := s.UnmarshalJSON([]byte(` + { + "type": "object", + "properties": { + "name": { + "type": "string", + "required": true + }, + "description": { + "type": "string" + } + } + } + `)) + require.NoError(t, err) + return s + }(), + }, + }, + wantErr: true, + }, + { + name: "no authenticators, ok", + ctx: CTX, + req: &schema.CreateUserSchemaRequest{ + Type: fmt.Sprint(time.Now().UnixNano() + 1), + DataType: &schema.CreateUserSchemaRequest_Schema{ + Schema: func() *structpb.Struct { + s := new(structpb.Struct) + err := s.UnmarshalJSON([]byte(` + { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + } + }, + "required": ["name"] + } + `)) + require.NoError(t, err) + return s + }(), + }, + }, + want: &schema.CreateUserSchemaResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Instance.InstanceID(), + }, + }, + }, + { + name: "invalid authenticator, error", + ctx: CTX, + req: &schema.CreateUserSchemaRequest{ + Type: fmt.Sprint(time.Now().UnixNano() + 1), + DataType: &schema.CreateUserSchemaRequest_Schema{ + Schema: func() *structpb.Struct { + s := new(structpb.Struct) + err := s.UnmarshalJSON([]byte(` + { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + } + }, + "required": ["name"] + } + `)) + require.NoError(t, err) + return s + }(), + }, + PossibleAuthenticators: []schema.AuthenticatorType{ + schema.AuthenticatorType_AUTHENTICATOR_TYPE_UNSPECIFIED, + }, + }, + wantErr: true, + }, + { + name: "with authenticator, ok", + ctx: CTX, + req: &schema.CreateUserSchemaRequest{ + Type: fmt.Sprint(time.Now().UnixNano() + 1), + DataType: &schema.CreateUserSchemaRequest_Schema{ + Schema: func() *structpb.Struct { + s := new(structpb.Struct) + err := s.UnmarshalJSON([]byte(` + { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + } + }, + "required": ["name"] + } + `)) + require.NoError(t, err) + return s + }(), + }, + PossibleAuthenticators: []schema.AuthenticatorType{ + schema.AuthenticatorType_AUTHENTICATOR_TYPE_USERNAME, + }, + }, + want: &schema.CreateUserSchemaResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Instance.InstanceID(), + }, + }, + }, + { + name: "with invalid permission, error", + ctx: CTX, + req: &schema.CreateUserSchemaRequest{ + Type: fmt.Sprint(time.Now().UnixNano() + 1), + DataType: &schema.CreateUserSchemaRequest_Schema{ + Schema: func() *structpb.Struct { + s := new(structpb.Struct) + err := s.UnmarshalJSON([]byte(` + { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string", + "urn:zitadel:schema:permission": "read" + } + }, + "required": ["name"] + } + `)) + require.NoError(t, err) + return s + }(), + }, + PossibleAuthenticators: []schema.AuthenticatorType{ + schema.AuthenticatorType_AUTHENTICATOR_TYPE_USERNAME, + }, + }, + wantErr: true, + }, + { + name: "with valid permission, ok", + ctx: CTX, + req: &schema.CreateUserSchemaRequest{ + Type: fmt.Sprint(time.Now().UnixNano() + 1), + DataType: &schema.CreateUserSchemaRequest_Schema{ + Schema: func() *structpb.Struct { + s := new(structpb.Struct) + err := s.UnmarshalJSON([]byte(` + { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string", + "urn:zitadel:schema:permission": { + "owner": "rw", + "self": "r" + } + } + }, + "required": ["name"] + } + `)) + require.NoError(t, err) + return s + }(), + }, + PossibleAuthenticators: []schema.AuthenticatorType{ + schema.AuthenticatorType_AUTHENTICATOR_TYPE_USERNAME, + }, + }, + want: &schema.CreateUserSchemaResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Instance.InstanceID(), + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ensureFeatureEnabled(t) + got, err := Client.CreateUserSchema(tt.ctx, tt.req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + + integration.AssertDetails(t, tt.want, got) + assert.NotEmpty(t, got.GetId()) + }) + } +} + +func TestServer_UpdateUserSchema(t *testing.T) { + type args struct { + ctx context.Context + req *schema.UpdateUserSchemaRequest + } + tests := []struct { + name string + prepare func(request *schema.UpdateUserSchemaRequest) error + args args + want *schema.UpdateUserSchemaResponse + wantErr bool + }{ + { + name: "missing permission, error", + prepare: func(request *schema.UpdateUserSchemaRequest) error { + schemaID := Tester.CreateUserSchema(CTX, t).GetId() + request.Id = schemaID + return nil + }, + args: args{ + ctx: Tester.WithAuthorization(context.Background(), integration.OrgOwner), + req: &schema.UpdateUserSchemaRequest{ + Type: gu.Ptr(fmt.Sprint(time.Now().UnixNano() + 1)), + }, + }, + wantErr: true, + }, + { + name: "missing id, error", + prepare: func(request *schema.UpdateUserSchemaRequest) error { + return nil + }, + args: args{ + ctx: CTX, + req: &schema.UpdateUserSchemaRequest{}, + }, + wantErr: true, + }, + { + name: "not existing, error", + prepare: func(request *schema.UpdateUserSchemaRequest) error { + request.Id = "notexisting" + return nil + }, + args: args{ + ctx: CTX, + req: &schema.UpdateUserSchemaRequest{}, + }, + wantErr: true, + }, + { + name: "empty type, error", + prepare: func(request *schema.UpdateUserSchemaRequest) error { + schemaID := Tester.CreateUserSchema(CTX, t).GetId() + request.Id = schemaID + return nil + }, + args: args{ + ctx: CTX, + req: &schema.UpdateUserSchemaRequest{ + Type: gu.Ptr(""), + }, + }, + wantErr: true, + }, + { + name: "update type, ok", + prepare: func(request *schema.UpdateUserSchemaRequest) error { + schemaID := Tester.CreateUserSchema(CTX, t).GetId() + request.Id = schemaID + return nil + }, + args: args{ + ctx: CTX, + req: &schema.UpdateUserSchemaRequest{ + Type: gu.Ptr(fmt.Sprint(time.Now().UnixNano() + 1)), + }, + }, + want: &schema.UpdateUserSchemaResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Instance.InstanceID(), + }, + }, + }, + { + name: "empty schema, ok", + prepare: func(request *schema.UpdateUserSchemaRequest) error { + schemaID := Tester.CreateUserSchema(CTX, t).GetId() + request.Id = schemaID + return nil + }, + args: args{ + ctx: CTX, + req: &schema.UpdateUserSchemaRequest{ + DataType: &schema.UpdateUserSchemaRequest_Schema{}, + }, + }, + want: &schema.UpdateUserSchemaResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Instance.InstanceID(), + }, + }, + }, + { + name: "invalid schema, error", + prepare: func(request *schema.UpdateUserSchemaRequest) error { + schemaID := Tester.CreateUserSchema(CTX, t).GetId() + request.Id = schemaID + return nil + }, + args: args{ + ctx: CTX, + req: &schema.UpdateUserSchemaRequest{ + DataType: &schema.UpdateUserSchemaRequest_Schema{ + Schema: func() *structpb.Struct { + s := new(structpb.Struct) + err := s.UnmarshalJSON([]byte(` + { + "$schema": "urn:zitadel:schema:v1", + "type": "object", + "properties": { + "name": { + "type": "string", + "required": true + }, + "description": { + "type": "string" + } + } + } + `)) + require.NoError(t, err) + return s + }(), + }, + }, + }, + wantErr: true, + }, + { + name: "update schema, ok", + prepare: func(request *schema.UpdateUserSchemaRequest) error { + schemaID := Tester.CreateUserSchema(CTX, t).GetId() + request.Id = schemaID + return nil + }, + args: args{ + ctx: CTX, + req: &schema.UpdateUserSchemaRequest{ + DataType: &schema.UpdateUserSchemaRequest_Schema{ + Schema: func() *structpb.Struct { + s := new(structpb.Struct) + err := s.UnmarshalJSON([]byte(` + { + "$schema": "urn:zitadel:schema:v1", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + } + }, + "required": ["name"] + } + `)) + require.NoError(t, err) + return s + }(), + }, + }, + }, + want: &schema.UpdateUserSchemaResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Instance.InstanceID(), + }, + }, + }, + { + name: "invalid authenticator, error", + prepare: func(request *schema.UpdateUserSchemaRequest) error { + schemaID := Tester.CreateUserSchema(CTX, t).GetId() + request.Id = schemaID + return nil + }, + args: args{ + ctx: CTX, + req: &schema.UpdateUserSchemaRequest{ + PossibleAuthenticators: []schema.AuthenticatorType{ + schema.AuthenticatorType_AUTHENTICATOR_TYPE_UNSPECIFIED, + }, + }, + }, + wantErr: true, + }, + { + name: "update authenticator, ok", + prepare: func(request *schema.UpdateUserSchemaRequest) error { + schemaID := Tester.CreateUserSchema(CTX, t).GetId() + request.Id = schemaID + return nil + }, + args: args{ + ctx: CTX, + req: &schema.UpdateUserSchemaRequest{ + PossibleAuthenticators: []schema.AuthenticatorType{ + schema.AuthenticatorType_AUTHENTICATOR_TYPE_USERNAME, + }, + }, + }, + want: &schema.UpdateUserSchemaResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Instance.InstanceID(), + }, + }, + }, + { + name: "inactive, error", + prepare: func(request *schema.UpdateUserSchemaRequest) error { + schemaID := Tester.CreateUserSchema(CTX, t).GetId() + _, err := Client.DeactivateUserSchema(CTX, &schema.DeactivateUserSchemaRequest{ + Id: schemaID, + }) + require.NoError(t, err) + request.Id = schemaID + return nil + }, + args: args{ + ctx: CTX, + req: &schema.UpdateUserSchemaRequest{ + Type: gu.Ptr(fmt.Sprint(time.Now().UnixNano() + 1)), + }, + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ensureFeatureEnabled(t) + err := tt.prepare(tt.args.req) + require.NoError(t, err) + + got, err := Client.UpdateUserSchema(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + integration.AssertDetails(t, tt.want, got) + }) + } +} + +func TestServer_DeactivateUserSchema(t *testing.T) { + type args struct { + ctx context.Context + req *schema.DeactivateUserSchemaRequest + prepare func(request *schema.DeactivateUserSchemaRequest) error + } + tests := []struct { + name string + args args + want *schema.DeactivateUserSchemaResponse + wantErr bool + }{ + { + name: "not existing, error", + args: args{ + CTX, + &schema.DeactivateUserSchemaRequest{ + Id: "notexisting", + }, + func(request *schema.DeactivateUserSchemaRequest) error { return nil }, + }, + wantErr: true, + }, + { + name: "active, ok", + args: args{ + CTX, + &schema.DeactivateUserSchemaRequest{}, + func(request *schema.DeactivateUserSchemaRequest) error { + schemaID := Tester.CreateUserSchema(CTX, t).GetId() + request.Id = schemaID + return nil + }, + }, + want: &schema.DeactivateUserSchemaResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Instance.InstanceID(), + }, + }, + }, + { + name: "inactive, error", + args: args{ + CTX, + &schema.DeactivateUserSchemaRequest{}, + func(request *schema.DeactivateUserSchemaRequest) error { + schemaID := Tester.CreateUserSchema(CTX, t).GetId() + request.Id = schemaID + _, err := Client.DeactivateUserSchema(CTX, &schema.DeactivateUserSchemaRequest{ + Id: schemaID, + }) + return err + }, + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ensureFeatureEnabled(t) + err := tt.args.prepare(tt.args.req) + require.NoError(t, err) + + got, err := Client.DeactivateUserSchema(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + integration.AssertDetails(t, tt.want, got) + }) + } +} + +func TestServer_ReactivateUserSchema(t *testing.T) { + type args struct { + ctx context.Context + req *schema.ReactivateUserSchemaRequest + prepare func(request *schema.ReactivateUserSchemaRequest) error + } + tests := []struct { + name string + args args + want *schema.ReactivateUserSchemaResponse + wantErr bool + }{ + { + name: "not existing, error", + args: args{ + CTX, + &schema.ReactivateUserSchemaRequest{ + Id: "notexisting", + }, + func(request *schema.ReactivateUserSchemaRequest) error { return nil }, + }, + wantErr: true, + }, + { + name: "active, error", + args: args{ + ctx: CTX, + req: &schema.ReactivateUserSchemaRequest{}, + prepare: func(request *schema.ReactivateUserSchemaRequest) error { + schemaID := Tester.CreateUserSchema(CTX, t).GetId() + request.Id = schemaID + return nil + }, + }, + wantErr: true, + }, + { + name: "inactive, ok", + args: args{ + ctx: CTX, + req: &schema.ReactivateUserSchemaRequest{}, + prepare: func(request *schema.ReactivateUserSchemaRequest) error { + schemaID := Tester.CreateUserSchema(CTX, t).GetId() + request.Id = schemaID + _, err := Client.DeactivateUserSchema(CTX, &schema.DeactivateUserSchemaRequest{ + Id: schemaID, + }) + return err + }, + }, + want: &schema.ReactivateUserSchemaResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Instance.InstanceID(), + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ensureFeatureEnabled(t) + err := tt.args.prepare(tt.args.req) + require.NoError(t, err) + + got, err := Client.ReactivateUserSchema(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + integration.AssertDetails(t, tt.want, got) + }) + } +} + +func TestServer_DeleteUserSchema(t *testing.T) { + type args struct { + ctx context.Context + req *schema.DeleteUserSchemaRequest + prepare func(request *schema.DeleteUserSchemaRequest) error + } + tests := []struct { + name string + args args + want *schema.DeleteUserSchemaResponse + wantErr bool + }{ + { + name: "not existing, error", + args: args{ + CTX, + &schema.DeleteUserSchemaRequest{ + Id: "notexisting", + }, + func(request *schema.DeleteUserSchemaRequest) error { return nil }, + }, + wantErr: true, + }, + { + name: "delete, ok", + args: args{ + ctx: CTX, + req: &schema.DeleteUserSchemaRequest{}, + prepare: func(request *schema.DeleteUserSchemaRequest) error { + schemaID := Tester.CreateUserSchema(CTX, t).GetId() + request.Id = schemaID + return nil + }, + }, + want: &schema.DeleteUserSchemaResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Instance.InstanceID(), + }, + }, + }, + { + name: "deleted, error", + args: args{ + ctx: CTX, + req: &schema.DeleteUserSchemaRequest{}, + prepare: func(request *schema.DeleteUserSchemaRequest) error { + schemaID := Tester.CreateUserSchema(CTX, t).GetId() + request.Id = schemaID + _, err := Client.DeleteUserSchema(CTX, &schema.DeleteUserSchemaRequest{ + Id: schemaID, + }) + return err + }, + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ensureFeatureEnabled(t) + err := tt.args.prepare(tt.args.req) + require.NoError(t, err) + + got, err := Client.DeleteUserSchema(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + integration.AssertDetails(t, tt.want, got) + }) + } +} diff --git a/internal/api/grpc/user/schema/v3alpha/server.go b/internal/api/grpc/user/schema/v3alpha/server.go new file mode 100644 index 0000000000..c02bb7c629 --- /dev/null +++ b/internal/api/grpc/user/schema/v3alpha/server.go @@ -0,0 +1,51 @@ +package schema + +import ( + "google.golang.org/grpc" + + "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/query" + schema "github.com/zitadel/zitadel/pkg/grpc/user/schema/v3alpha" +) + +var _ schema.UserSchemaServiceServer = (*Server)(nil) + +type Server struct { + schema.UnimplementedUserSchemaServiceServer + command *command.Commands + query *query.Queries +} + +type Config struct{} + +func CreateServer( + command *command.Commands, + query *query.Queries, +) *Server { + return &Server{ + command: command, + query: query, + } +} + +func (s *Server) RegisterServer(grpcServer *grpc.Server) { + schema.RegisterUserSchemaServiceServer(grpcServer, s) +} + +func (s *Server) AppName() string { + return schema.UserSchemaService_ServiceDesc.ServiceName +} + +func (s *Server) MethodPrefix() string { + return schema.UserSchemaService_ServiceDesc.ServiceName +} + +func (s *Server) AuthMethods() authz.MethodMapping { + return schema.UserSchemaService_AuthMethods +} + +func (s *Server) RegisterGateway() server.RegisterGatewayFunc { + return schema.RegisterUserSchemaServiceHandler +} diff --git a/internal/command/instance_features.go b/internal/command/instance_features.go index 171ef8f6b7..72ded66632 100644 --- a/internal/command/instance_features.go +++ b/internal/command/instance_features.go @@ -15,12 +15,14 @@ type InstanceFeatures struct { LoginDefaultOrg *bool TriggerIntrospectionProjections *bool LegacyIntrospection *bool + UserSchema *bool } func (m *InstanceFeatures) isEmpty() bool { return m.LoginDefaultOrg == nil && m.TriggerIntrospectionProjections == nil && - m.LegacyIntrospection == nil + m.LegacyIntrospection == nil && + m.UserSchema == nil } func (c *Commands) SetInstanceFeatures(ctx context.Context, f *InstanceFeatures) (*domain.ObjectDetails, error) { diff --git a/internal/command/instance_features_model.go b/internal/command/instance_features_model.go index 7f618099d9..7938429c40 100644 --- a/internal/command/instance_features_model.go +++ b/internal/command/instance_features_model.go @@ -54,6 +54,7 @@ func (m *InstanceFeaturesWriteModel) Query() *eventstore.SearchQueryBuilder { feature_v2.InstanceLoginDefaultOrgEventType, feature_v2.InstanceTriggerIntrospectionProjectionsEventType, feature_v2.InstanceLegacyIntrospectionEventType, + feature_v2.InstanceUserSchemaEventType, ). Builder().ResourceOwner(m.ResourceOwner) } @@ -62,6 +63,7 @@ func (m *InstanceFeaturesWriteModel) reduceReset() { m.LoginDefaultOrg = nil m.TriggerIntrospectionProjections = nil m.LegacyIntrospection = nil + m.UserSchema = nil } func (m *InstanceFeaturesWriteModel) reduceBoolFeature(event *feature_v2.SetEvent[bool]) error { @@ -78,6 +80,8 @@ func (m *InstanceFeaturesWriteModel) reduceBoolFeature(event *feature_v2.SetEven m.TriggerIntrospectionProjections = &event.Value case feature.KeyLegacyIntrospection: m.LegacyIntrospection = &event.Value + case feature.KeyUserSchema: + m.UserSchema = &event.Value } return nil } @@ -88,5 +92,6 @@ func (wm *InstanceFeaturesWriteModel) setCommands(ctx context.Context, f *Instan cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.LoginDefaultOrg, f.LoginDefaultOrg, feature_v2.InstanceLoginDefaultOrgEventType) cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.TriggerIntrospectionProjections, f.TriggerIntrospectionProjections, feature_v2.InstanceTriggerIntrospectionProjectionsEventType) cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.LegacyIntrospection, f.LegacyIntrospection, feature_v2.InstanceLegacyIntrospectionEventType) + cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.UserSchema, f.UserSchema, feature_v2.InstanceUserSchemaEventType) return cmds } diff --git a/internal/command/instance_features_test.go b/internal/command/instance_features_test.go index fd3439ea32..0130d91de9 100644 --- a/internal/command/instance_features_test.go +++ b/internal/command/instance_features_test.go @@ -131,6 +131,24 @@ func TestCommands_SetInstanceFeatures(t *testing.T) { ResourceOwner: "instance1", }, }, + { + name: "set UserSchema", + eventstore: expectEventstore( + expectFilter(), + expectPush( + feature_v2.NewSetEvent[bool]( + ctx, aggregate, + feature_v2.InstanceUserSchemaEventType, true, + ), + ), + ), + args: args{ctx, &InstanceFeatures{ + UserSchema: gu.Ptr(true), + }}, + want: &domain.ObjectDetails{ + ResourceOwner: "instance1", + }, + }, { name: "push error", eventstore: expectEventstore( @@ -164,12 +182,17 @@ func TestCommands_SetInstanceFeatures(t *testing.T) { ctx, aggregate, feature_v2.InstanceLegacyIntrospectionEventType, true, ), + feature_v2.NewSetEvent[bool]( + ctx, aggregate, + feature_v2.InstanceUserSchemaEventType, true, + ), ), ), args: args{ctx, &InstanceFeatures{ LoginDefaultOrg: gu.Ptr(true), TriggerIntrospectionProjections: gu.Ptr(false), LegacyIntrospection: gu.Ptr(true), + UserSchema: gu.Ptr(true), }}, want: &domain.ObjectDetails{ ResourceOwner: "instance1", diff --git a/internal/command/system_features.go b/internal/command/system_features.go index 4642c25aba..4311c3b230 100644 --- a/internal/command/system_features.go +++ b/internal/command/system_features.go @@ -12,12 +12,14 @@ type SystemFeatures struct { LoginDefaultOrg *bool TriggerIntrospectionProjections *bool LegacyIntrospection *bool + UserSchema *bool } func (m *SystemFeatures) isEmpty() bool { return m.LoginDefaultOrg == nil && m.TriggerIntrospectionProjections == nil && - m.LegacyIntrospection == nil + m.LegacyIntrospection == nil && + m.UserSchema == nil } func (c *Commands) SetSystemFeatures(ctx context.Context, f *SystemFeatures) (*domain.ObjectDetails, error) { diff --git a/internal/command/system_features_model.go b/internal/command/system_features_model.go index 91a29044c5..5057f55f34 100644 --- a/internal/command/system_features_model.go +++ b/internal/command/system_features_model.go @@ -49,6 +49,7 @@ func (m *SystemFeaturesWriteModel) Query() *eventstore.SearchQueryBuilder { feature_v2.SystemLoginDefaultOrgEventType, feature_v2.SystemTriggerIntrospectionProjectionsEventType, feature_v2.SystemLegacyIntrospectionEventType, + feature_v2.SystemUserSchemaEventType, ). Builder().ResourceOwner(m.ResourceOwner) } @@ -57,6 +58,7 @@ func (m *SystemFeaturesWriteModel) reduceReset() { m.LoginDefaultOrg = nil m.TriggerIntrospectionProjections = nil m.LegacyIntrospection = nil + m.UserSchema = nil } func (m *SystemFeaturesWriteModel) reduceBoolFeature(event *feature_v2.SetEvent[bool]) error { @@ -73,6 +75,8 @@ func (m *SystemFeaturesWriteModel) reduceBoolFeature(event *feature_v2.SetEvent[ m.TriggerIntrospectionProjections = &event.Value case feature.KeyLegacyIntrospection: m.LegacyIntrospection = &event.Value + case feature.KeyUserSchema: + m.UserSchema = &event.Value } return nil } @@ -83,6 +87,7 @@ func (wm *SystemFeaturesWriteModel) setCommands(ctx context.Context, f *SystemFe cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.LoginDefaultOrg, f.LoginDefaultOrg, feature_v2.SystemLoginDefaultOrgEventType) cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.TriggerIntrospectionProjections, f.TriggerIntrospectionProjections, feature_v2.SystemTriggerIntrospectionProjectionsEventType) cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.LegacyIntrospection, f.LegacyIntrospection, feature_v2.SystemLegacyIntrospectionEventType) + cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.UserSchema, f.UserSchema, feature_v2.SystemUserSchemaEventType) return cmds } diff --git a/internal/command/system_features_test.go b/internal/command/system_features_test.go index 5307e84846..e426721e36 100644 --- a/internal/command/system_features_test.go +++ b/internal/command/system_features_test.go @@ -99,6 +99,24 @@ func TestCommands_SetSystemFeatures(t *testing.T) { ResourceOwner: "SYSTEM", }, }, + { + name: "set UserSchema", + eventstore: expectEventstore( + expectFilter(), + expectPush( + feature_v2.NewSetEvent[bool]( + context.Background(), aggregate, + feature_v2.SystemUserSchemaEventType, true, + ), + ), + ), + args: args{context.Background(), &SystemFeatures{ + UserSchema: gu.Ptr(true), + }}, + want: &domain.ObjectDetails{ + ResourceOwner: "SYSTEM", + }, + }, { name: "push error", eventstore: expectEventstore( @@ -132,12 +150,17 @@ func TestCommands_SetSystemFeatures(t *testing.T) { context.Background(), aggregate, feature_v2.SystemLegacyIntrospectionEventType, true, ), + feature_v2.NewSetEvent[bool]( + context.Background(), aggregate, + feature_v2.SystemUserSchemaEventType, true, + ), ), ), args: args{context.Background(), &SystemFeatures{ LoginDefaultOrg: gu.Ptr(true), TriggerIntrospectionProjections: gu.Ptr(false), LegacyIntrospection: gu.Ptr(true), + UserSchema: gu.Ptr(true), }}, want: &domain.ObjectDetails{ ResourceOwner: "SYSTEM", @@ -178,12 +201,17 @@ func TestCommands_SetSystemFeatures(t *testing.T) { context.Background(), aggregate, feature_v2.SystemTriggerIntrospectionProjectionsEventType, false, ), + feature_v2.NewSetEvent[bool]( + context.Background(), aggregate, + feature_v2.SystemUserSchemaEventType, true, + ), ), ), args: args{context.Background(), &SystemFeatures{ LoginDefaultOrg: gu.Ptr(true), TriggerIntrospectionProjections: gu.Ptr(false), LegacyIntrospection: gu.Ptr(true), + UserSchema: gu.Ptr(true), }}, want: &domain.ObjectDetails{ ResourceOwner: "SYSTEM", diff --git a/internal/command/user_schema.go b/internal/command/user_schema.go new file mode 100644 index 0000000000..507e0caced --- /dev/null +++ b/internal/command/user_schema.go @@ -0,0 +1,180 @@ +package command + +import ( + "bytes" + "context" + "encoding/json" + + "github.com/zitadel/zitadel/internal/domain" + domain_schema "github.com/zitadel/zitadel/internal/domain/schema" + "github.com/zitadel/zitadel/internal/repository/user/schema" + "github.com/zitadel/zitadel/internal/zerrors" +) + +type CreateUserSchema struct { + ResourceOwner string + Type string + Schema json.RawMessage + PossibleAuthenticators []domain.AuthenticatorType +} + +func (s *CreateUserSchema) Valid() error { + if s.Type == "" { + return zerrors.ThrowInvalidArgument(nil, "COMMA-DGFj3", "Errors.UserSchema.Type.Missing") + } + if err := validateUserSchema(s.Schema); err != nil { + return err + } + for _, authenticator := range s.PossibleAuthenticators { + if authenticator == domain.AuthenticatorTypeUnspecified { + return zerrors.ThrowInvalidArgument(nil, "COMMA-Gh652", "Errors.UserSchema.Authenticator.Invalid") + } + } + return nil +} + +type UpdateUserSchema struct { + ID string + ResourceOwner string + Type *string + Schema json.RawMessage + PossibleAuthenticators []domain.AuthenticatorType +} + +func (s *UpdateUserSchema) Valid() error { + if s.ID == "" { + return zerrors.ThrowInvalidArgument(nil, "COMMA-H5421", "Errors.IDMissing") + } + if s.Type != nil && *s.Type == "" { + return zerrors.ThrowInvalidArgument(nil, "COMMA-G43gn", "Errors.UserSchema.Type.Missing") + } + if err := validateUserSchema(s.Schema); err != nil { + return err + } + for _, authenticator := range s.PossibleAuthenticators { + if authenticator == domain.AuthenticatorTypeUnspecified { + return zerrors.ThrowInvalidArgument(nil, "COMMA-WF4hg", "Errors.UserSchema.Authenticator.Invalid") + } + } + return nil +} + +func (c *Commands) CreateUserSchema(ctx context.Context, userSchema *CreateUserSchema) (string, *domain.ObjectDetails, error) { + if err := userSchema.Valid(); err != nil { + return "", nil, err + } + if userSchema.ResourceOwner == "" { + return "", nil, zerrors.ThrowInvalidArgument(nil, "COMMA-J3hhj", "Errors.ResourceOwnerMissing") + } + id, err := c.idGenerator.Next() + if err != nil { + return "", nil, err + } + writeModel := NewUserSchemaWriteModel(id, userSchema.ResourceOwner) + err = c.pushAppendAndReduce(ctx, writeModel, + schema.NewCreatedEvent(ctx, + UserSchemaAggregateFromWriteModel(&writeModel.WriteModel), + userSchema.Type, userSchema.Schema, userSchema.PossibleAuthenticators, + ), + ) + if err != nil { + return "", nil, err + } + return id, writeModelToObjectDetails(&writeModel.WriteModel), nil +} + +func (c *Commands) UpdateUserSchema(ctx context.Context, userSchema *UpdateUserSchema) (*domain.ObjectDetails, error) { + if err := userSchema.Valid(); err != nil { + return nil, err + } + writeModel := NewUserSchemaWriteModel(userSchema.ID, userSchema.ResourceOwner) + if err := c.eventstore.FilterToQueryReducer(ctx, writeModel); err != nil { + return nil, err + } + if writeModel.State != domain.UserSchemaStateActive { + return nil, zerrors.ThrowPreconditionFailed(nil, "COMMA-HB3e1", "Errors.UserSchema.NotActive") + } + updatedEvent := writeModel.NewUpdatedEvent( + ctx, + UserSchemaAggregateFromWriteModel(&writeModel.WriteModel), + userSchema.Type, + userSchema.Schema, + userSchema.PossibleAuthenticators, + ) + if updatedEvent == nil { + return writeModelToObjectDetails(&writeModel.WriteModel), nil + } + if err := c.pushAppendAndReduce(ctx, writeModel, updatedEvent); err != nil { + return nil, err + } + return writeModelToObjectDetails(&writeModel.WriteModel), nil +} + +func (c *Commands) DeactivateUserSchema(ctx context.Context, id, resourceOwner string) (*domain.ObjectDetails, error) { + if id == "" { + return nil, zerrors.ThrowInvalidArgument(nil, "COMMA-Vvf3w", "Errors.IDMissing") + } + writeModel := NewUserSchemaWriteModel(id, resourceOwner) + if err := c.eventstore.FilterToQueryReducer(ctx, writeModel); err != nil { + return nil, err + } + if writeModel.State != domain.UserSchemaStateActive { + return nil, zerrors.ThrowPreconditionFailed(nil, "COMMA-E4t4z", "Errors.UserSchema.NotActive") + } + err := c.pushAppendAndReduce(ctx, writeModel, + schema.NewDeactivatedEvent(ctx, UserSchemaAggregateFromWriteModel(&writeModel.WriteModel)), + ) + if err != nil { + return nil, err + } + return writeModelToObjectDetails(&writeModel.WriteModel), nil +} + +func (c *Commands) ReactivateUserSchema(ctx context.Context, id, resourceOwner string) (*domain.ObjectDetails, error) { + if id == "" { + return nil, zerrors.ThrowInvalidArgument(nil, "COMMA-wq3Gw", "Errors.IDMissing") + } + writeModel := NewUserSchemaWriteModel(id, resourceOwner) + if err := c.eventstore.FilterToQueryReducer(ctx, writeModel); err != nil { + return nil, err + } + if writeModel.State != domain.UserSchemaStateInactive { + return nil, zerrors.ThrowPreconditionFailed(nil, "COMMA-DGzh5", "Errors.UserSchema.NotInactive") + } + err := c.pushAppendAndReduce(ctx, writeModel, + schema.NewReactivatedEvent(ctx, UserSchemaAggregateFromWriteModel(&writeModel.WriteModel)), + ) + if err != nil { + return nil, err + } + return writeModelToObjectDetails(&writeModel.WriteModel), nil +} + +func (c *Commands) DeleteUserSchema(ctx context.Context, id, resourceOwner string) (*domain.ObjectDetails, error) { + if id == "" { + return nil, zerrors.ThrowInvalidArgument(nil, "COMMA-E22gg", "Errors.IDMissing") + } + writeModel := NewUserSchemaWriteModel(id, resourceOwner) + if err := c.eventstore.FilterToQueryReducer(ctx, writeModel); err != nil { + return nil, err + } + if !writeModel.Exists() { + return nil, zerrors.ThrowPreconditionFailed(nil, "COMMA-Grg41", "Errors.UserSchema.NotExists") + } + // TODO: check for users based on that schema; this is only possible with / after https://github.com/zitadel/zitadel/issues/7308 + err := c.pushAppendAndReduce(ctx, writeModel, + schema.NewDeletedEvent(ctx, UserSchemaAggregateFromWriteModel(&writeModel.WriteModel), writeModel.SchemaType), + ) + if err != nil { + return nil, err + } + return writeModelToObjectDetails(&writeModel.WriteModel), nil +} + +func validateUserSchema(userSchema json.RawMessage) error { + _, err := domain_schema.NewSchema(0, bytes.NewReader(userSchema)) + if err != nil { + return zerrors.ThrowInvalidArgument(err, "COMMA-W21tg", "Errors.UserSchema.Schema.Invalid") + } + return nil +} diff --git a/internal/command/user_schema_model.go b/internal/command/user_schema_model.go new file mode 100644 index 0000000000..ccb5fbf27b --- /dev/null +++ b/internal/command/user_schema_model.go @@ -0,0 +1,112 @@ +package command + +import ( + "bytes" + "context" + "encoding/json" + + "golang.org/x/exp/slices" + + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/repository/user/schema" +) + +type UserSchemaWriteModel struct { + eventstore.WriteModel + + SchemaType string + Schema json.RawMessage + PossibleAuthenticators []domain.AuthenticatorType + State domain.UserSchemaState +} + +func NewUserSchemaWriteModel(schemaID, resourceOwner string) *UserSchemaWriteModel { + return &UserSchemaWriteModel{ + WriteModel: eventstore.WriteModel{ + AggregateID: schemaID, + ResourceOwner: resourceOwner, + }, + } +} + +func (wm *UserSchemaWriteModel) Reduce() error { + for _, event := range wm.Events { + switch e := event.(type) { + case *schema.CreatedEvent: + wm.SchemaType = e.SchemaType + wm.Schema = e.Schema + wm.PossibleAuthenticators = e.PossibleAuthenticators + wm.State = domain.UserSchemaStateActive + case *schema.UpdatedEvent: + if e.SchemaType != nil { + wm.SchemaType = *e.SchemaType + } + if len(e.Schema) > 0 { + wm.Schema = e.Schema + } + if len(e.PossibleAuthenticators) > 0 { + wm.PossibleAuthenticators = e.PossibleAuthenticators + } + case *schema.DeactivatedEvent: + wm.State = domain.UserSchemaStateInactive + case *schema.ReactivatedEvent: + wm.State = domain.UserSchemaStateActive + case *schema.DeletedEvent: + wm.State = domain.UserSchemaStateDeleted + } + } + return wm.WriteModel.Reduce() +} + +func (wm *UserSchemaWriteModel) Query() *eventstore.SearchQueryBuilder { + return eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent). + ResourceOwner(wm.ResourceOwner). + AddQuery(). + AggregateTypes(schema.AggregateType). + AggregateIDs(wm.AggregateID). + EventTypes( + schema.CreatedType, + schema.UpdatedType, + schema.DeactivatedType, + schema.ReactivatedType, + schema.DeletedType, + ). + Builder() +} +func (wm *UserSchemaWriteModel) NewUpdatedEvent( + ctx context.Context, + agg *eventstore.Aggregate, + schemaType *string, + userSchema json.RawMessage, + possibleAuthenticators []domain.AuthenticatorType, +) *schema.UpdatedEvent { + changes := make([]schema.Changes, 0) + if schemaType != nil && wm.SchemaType != *schemaType { + changes = append(changes, schema.ChangeSchemaType(wm.SchemaType, *schemaType)) + } + if !bytes.Equal(wm.Schema, userSchema) { + changes = append(changes, schema.ChangeSchema(userSchema)) + } + if len(possibleAuthenticators) > 0 && slices.Compare(wm.PossibleAuthenticators, possibleAuthenticators) != 0 { + changes = append(changes, schema.ChangePossibleAuthenticators(possibleAuthenticators)) + } + if len(changes) == 0 { + return nil + } + return schema.NewUpdatedEvent(ctx, agg, changes) +} + +func UserSchemaAggregateFromWriteModel(wm *eventstore.WriteModel) *eventstore.Aggregate { + return &eventstore.Aggregate{ + ID: wm.AggregateID, + Type: schema.AggregateType, + ResourceOwner: wm.ResourceOwner, + InstanceID: wm.InstanceID, + Version: schema.AggregateVersion, + } +} + +func (wm *UserSchemaWriteModel) Exists() bool { + return wm.State != domain.UserSchemaStateUnspecified && wm.State != domain.UserSchemaStateDeleted +} diff --git a/internal/command/user_schema_test.go b/internal/command/user_schema_test.go new file mode 100644 index 0000000000..717620b44f --- /dev/null +++ b/internal/command/user_schema_test.go @@ -0,0 +1,912 @@ +package command + +import ( + "context" + "encoding/json" + "testing" + + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/id" + "github.com/zitadel/zitadel/internal/id/mock" + "github.com/zitadel/zitadel/internal/repository/user/schema" + "github.com/zitadel/zitadel/internal/zerrors" +) + +func TestCommands_CreateUserSchema(t *testing.T) { + type fields struct { + eventstore func(t *testing.T) *eventstore.Eventstore + idGenerator id.Generator + } + type args struct { + ctx context.Context + userSchema *CreateUserSchema + } + type res struct { + id string + details *domain.ObjectDetails + err error + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + "no type, error", + fields{ + eventstore: expectEventstore(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + userSchema: &CreateUserSchema{}, + }, + res{ + err: zerrors.ThrowInvalidArgument(nil, "COMMA-DGFj3", "Errors.UserSchema.Type.Missing"), + }, + }, + { + "no schema, error", + fields{ + eventstore: expectEventstore(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + userSchema: &CreateUserSchema{ + Type: "type", + }, + }, + res{ + err: zerrors.ThrowInvalidArgument(nil, "COMMA-W21tg", "Errors.UserSchema.Schema.Invalid"), + }, + }, + { + "invalid authenticator, error", + fields{ + eventstore: expectEventstore(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + userSchema: &CreateUserSchema{ + Type: "type", + Schema: json.RawMessage(`{}`), + PossibleAuthenticators: []domain.AuthenticatorType{ + domain.AuthenticatorTypeUnspecified, + }, + }, + }, + res{ + err: zerrors.ThrowInvalidArgument(nil, "COMMA-Gh652", "Errors.UserSchema.Authenticator.Invalid"), + }, + }, + { + "no resourceOwner, error", + fields{ + eventstore: expectEventstore(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + userSchema: &CreateUserSchema{ + Type: "type", + Schema: json.RawMessage(`{}`), + PossibleAuthenticators: []domain.AuthenticatorType{ + domain.AuthenticatorTypeUsername, + }, + }, + }, + res{ + err: zerrors.ThrowInvalidArgument(nil, "COMMA-J3hhj", "Errors.ResourceOwnerMissing"), + }, + }, + { + "empty user schema created", + fields{ + eventstore: expectEventstore( + expectPush( + schema.NewCreatedEvent( + context.Background(), + &schema.NewAggregate("id1", "instanceID").Aggregate, + "type", + json.RawMessage(`{}`), + []domain.AuthenticatorType{domain.AuthenticatorTypeUsername}, + ), + ), + ), + idGenerator: mock.ExpectID(t, "id1"), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + userSchema: &CreateUserSchema{ + ResourceOwner: "instanceID", + Type: "type", + Schema: json.RawMessage(`{}`), + PossibleAuthenticators: []domain.AuthenticatorType{ + domain.AuthenticatorTypeUsername, + }, + }, + }, + res{ + id: "id1", + details: &domain.ObjectDetails{ + ResourceOwner: "instanceID", + }, + }, + }, + { + "user schema created", + fields{ + eventstore: expectEventstore( + expectPush( + schema.NewCreatedEvent( + context.Background(), + &schema.NewAggregate("id1", "instanceID").Aggregate, + "type", + json.RawMessage(`{ + "$schema": "urn:zitadel:schema:v1", + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + }`), + []domain.AuthenticatorType{domain.AuthenticatorTypeUsername}, + ), + ), + ), + idGenerator: mock.ExpectID(t, "id1"), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + userSchema: &CreateUserSchema{ + ResourceOwner: "instanceID", + Type: "type", + Schema: json.RawMessage(`{ + "$schema": "urn:zitadel:schema:v1", + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + }`), + PossibleAuthenticators: []domain.AuthenticatorType{ + domain.AuthenticatorTypeUsername, + }, + }, + }, + res{ + id: "id1", + details: &domain.ObjectDetails{ + ResourceOwner: "instanceID", + }, + }, + }, + { + "user schema with invalid permission, error", + fields{ + eventstore: expectEventstore(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + userSchema: &CreateUserSchema{ + ResourceOwner: "instanceID", + Type: "type", + Schema: json.RawMessage(`{ + "$schema": "urn:zitadel:schema:v1", + "type": "object", + "properties": { + "name": { + "type": "string", + "urn:zitadel:schema:permission": true + } + } + }`), + PossibleAuthenticators: []domain.AuthenticatorType{ + domain.AuthenticatorTypeUsername, + }, + }, + }, + res{ + err: zerrors.ThrowInvalidArgument(nil, "COMMA-W21tg", "Errors.UserSchema.Schema.Invalid"), + }, + }, + { + "user schema with permission created", + fields{ + eventstore: expectEventstore( + expectPush( + schema.NewCreatedEvent( + context.Background(), + &schema.NewAggregate("id1", "instanceID").Aggregate, + "type", + json.RawMessage(`{ + "$schema": "urn:zitadel:schema:v1", + "type": "object", + "properties": { + "name": { + "type": "string", + "urn:zitadel:schema:permission": { + "self": "rw" + } + } + } + }`), + []domain.AuthenticatorType{domain.AuthenticatorTypeUsername}, + ), + ), + ), + idGenerator: mock.ExpectID(t, "id1"), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + userSchema: &CreateUserSchema{ + ResourceOwner: "instanceID", + Type: "type", + Schema: json.RawMessage(`{ + "$schema": "urn:zitadel:schema:v1", + "type": "object", + "properties": { + "name": { + "type": "string", + "urn:zitadel:schema:permission": { + "self": "rw" + } + } + } + }`), + PossibleAuthenticators: []domain.AuthenticatorType{ + domain.AuthenticatorTypeUsername, + }, + }, + }, + res{ + id: "id1", + details: &domain.ObjectDetails{ + ResourceOwner: "instanceID", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore(t), + idGenerator: tt.fields.idGenerator, + } + gotID, gotDetails, err := c.CreateUserSchema(tt.args.ctx, tt.args.userSchema) + assert.Equal(t, tt.res.id, gotID) + assert.Equal(t, tt.res.details, gotDetails) + assert.ErrorIs(t, err, tt.res.err) + }) + } +} + +func TestCommands_UpdateUserSchema(t *testing.T) { + type fields struct { + eventstore func(t *testing.T) *eventstore.Eventstore + } + type args struct { + ctx context.Context + userSchema *UpdateUserSchema + } + type res struct { + details *domain.ObjectDetails + err error + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + "missing id, error", + fields{ + eventstore: expectEventstore(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + userSchema: &UpdateUserSchema{}, + }, + res{ + err: zerrors.ThrowInvalidArgument(nil, "COMMA-H5421", "Errors.IDMissing"), + }, + }, + { + "empty type, error", + fields{ + eventstore: expectEventstore(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + userSchema: &UpdateUserSchema{ + ID: "id1", + Type: gu.Ptr(""), + }, + }, + res{ + err: zerrors.ThrowInvalidArgument(nil, "COMMA-G43gn", "Errors.UserSchema.Type.Missing"), + }, + }, + { + "no schema, error", + fields{ + eventstore: expectEventstore(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + userSchema: &UpdateUserSchema{ + ID: "id1", + }, + }, + res{ + err: zerrors.ThrowInvalidArgument(nil, "COMMA-W21tg", "Errors.UserSchema.Schema.Invalid"), + }, + }, + { + "invalid schema, error", + fields{ + eventstore: expectEventstore(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + userSchema: &UpdateUserSchema{ + ID: "id1", + Schema: json.RawMessage(`{ + "properties": { + "name": { + "type": "string", + "required": true, + } + } + }`), + }, + }, + res{ + err: zerrors.ThrowInvalidArgument(nil, "COMMA-W21tg", "Errors.UserSchema.Schema.Invalid"), + }, + }, + { + "invalid authenticator, error", + fields{ + eventstore: expectEventstore(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + userSchema: &UpdateUserSchema{ + ID: "id1", + Schema: json.RawMessage(`{}`), + PossibleAuthenticators: []domain.AuthenticatorType{ + domain.AuthenticatorTypeUnspecified, + }, + }, + }, + res{ + err: zerrors.ThrowInvalidArgument(nil, "COMMA-WF4hg", "Errors.UserSchema.Authenticator.Invalid"), + }, + }, + { + "not active / exists, error", + fields{ + eventstore: expectEventstore( + expectFilter(), + ), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + userSchema: &UpdateUserSchema{ + ID: "id1", + Type: gu.Ptr("type"), + Schema: json.RawMessage(`{}`), + PossibleAuthenticators: []domain.AuthenticatorType{ + domain.AuthenticatorTypeUsername, + }, + }, + }, + res{ + err: zerrors.ThrowPreconditionFailed(nil, "COMMA-HB3e1", "Errors.UserSchema.NotActive"), + }, + }, + { + "no changes", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schema.NewCreatedEvent( + context.Background(), + &schema.NewAggregate("id1", "instanceID").Aggregate, + "type", + json.RawMessage(`{}`), + []domain.AuthenticatorType{domain.AuthenticatorTypeUsername}, + ), + ), + ), + ), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + userSchema: &UpdateUserSchema{ + ID: "id1", + Type: gu.Ptr("type"), + Schema: json.RawMessage(`{}`), + PossibleAuthenticators: []domain.AuthenticatorType{ + domain.AuthenticatorTypeUsername, + }, + }, + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "instanceID", + }, + }, + }, + { + "update type", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schema.NewCreatedEvent( + context.Background(), + &schema.NewAggregate("id1", "instanceID").Aggregate, + "type", + json.RawMessage(`{}`), + []domain.AuthenticatorType{domain.AuthenticatorTypeUsername}, + ), + ), + ), + expectPush( + schema.NewUpdatedEvent( + context.Background(), + &schema.NewAggregate("id1", "instanceID").Aggregate, + []schema.Changes{schema.ChangeSchemaType("type", "newType")}, + ), + ), + ), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + userSchema: &UpdateUserSchema{ + ID: "id1", + Schema: json.RawMessage(`{}`), + Type: gu.Ptr("newType"), + }, + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "instanceID", + }, + }, + }, + { + "update schema", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schema.NewCreatedEvent( + context.Background(), + &schema.NewAggregate("id1", "instanceID").Aggregate, + "type", + json.RawMessage(`{ + "$schema": "urn:zitadel:schema:v1", + "type": "object", + "properties": { + "name": { + "type": "string", + "urn:zitadel:schema:permission": { + "self": "rw" + } + } + } + }`), + []domain.AuthenticatorType{domain.AuthenticatorTypeUsername}, + ), + ), + ), + expectPush( + schema.NewUpdatedEvent( + context.Background(), + &schema.NewAggregate("id1", "instanceID").Aggregate, + []schema.Changes{schema.ChangeSchema(json.RawMessage(`{ + "$schema": "urn:zitadel:schema:v1", + "type": "object", + "properties": { + "name": { + "type": "string", + "urn:zitadel:schema:permission": { + "self": "rw" + } + }, + "description": { + "type": "string", + "urn:zitadel:schema:permission": { + "self": "rw" + } + } + } + }`))}, + ), + ), + ), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + userSchema: &UpdateUserSchema{ + ID: "id1", + Schema: json.RawMessage(`{ + "$schema": "urn:zitadel:schema:v1", + "type": "object", + "properties": { + "name": { + "type": "string", + "urn:zitadel:schema:permission": { + "self": "rw" + } + }, + "description": { + "type": "string", + "urn:zitadel:schema:permission": { + "self": "rw" + } + } + } + }`), + Type: nil, + }, + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "instanceID", + }, + }, + }, + { + "update possible authenticators", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schema.NewCreatedEvent( + context.Background(), + &schema.NewAggregate("id1", "instanceID").Aggregate, + "type", + json.RawMessage(`{}`), + []domain.AuthenticatorType{domain.AuthenticatorTypeUsername}, + ), + ), + ), + expectPush( + schema.NewUpdatedEvent( + context.Background(), + &schema.NewAggregate("id1", "instanceID").Aggregate, + []schema.Changes{schema.ChangePossibleAuthenticators([]domain.AuthenticatorType{ + domain.AuthenticatorTypeUsername, + domain.AuthenticatorTypePassword, + })}, + ), + ), + ), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + userSchema: &UpdateUserSchema{ + ID: "id1", + Schema: json.RawMessage(`{}`), + PossibleAuthenticators: []domain.AuthenticatorType{ + domain.AuthenticatorTypeUsername, + domain.AuthenticatorTypePassword, + }, + }, + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "instanceID", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore(t), + } + got, err := c.UpdateUserSchema(tt.args.ctx, tt.args.userSchema) + assert.ErrorIs(t, err, tt.res.err) + assert.Equal(t, tt.res.details, got) + }) + } +} + +func TestCommands_DeactivateUserSchema(t *testing.T) { + type fields struct { + eventstore func(t *testing.T) *eventstore.Eventstore + } + type args struct { + ctx context.Context + id string + resourceOwner string + } + type res struct { + details *domain.ObjectDetails + err error + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + "missing id, error", + fields{ + eventstore: expectEventstore(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + id: "", + }, + res{ + err: zerrors.ThrowInvalidArgument(nil, "COMMA-Vvf3w", "Errors.IDMissing"), + }, + }, + { + "not active / exists, error", + fields{ + eventstore: expectEventstore( + expectFilter(), + ), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + id: "id1", + }, + res{ + err: zerrors.ThrowPreconditionFailed(nil, "COMMA-E4t4z", "Errors.UserSchema.NotActive"), + }, + }, + { + "deactivate ok", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schema.NewCreatedEvent( + context.Background(), + &schema.NewAggregate("id1", "instanceID").Aggregate, + "type", + json.RawMessage(`{}`), + []domain.AuthenticatorType{domain.AuthenticatorTypeUsername}, + ), + ), + ), + expectPush( + schema.NewDeactivatedEvent( + context.Background(), + &schema.NewAggregate("id1", "instanceID").Aggregate, + ), + ), + ), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + id: "id1", + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "instanceID", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore(t), + } + got, err := c.DeactivateUserSchema(tt.args.ctx, tt.args.id, tt.args.resourceOwner) + assert.ErrorIs(t, err, tt.res.err) + assert.Equal(t, tt.res.details, got) + }) + } +} + +func TestCommands_ReactivateUserSchema(t *testing.T) { + type fields struct { + eventstore func(t *testing.T) *eventstore.Eventstore + } + type args struct { + ctx context.Context + id string + resourceOwner string + } + type res struct { + details *domain.ObjectDetails + err error + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + "missing id, error", + fields{ + eventstore: expectEventstore(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + id: "", + }, + res{ + err: zerrors.ThrowInvalidArgument(nil, "COMMA-wq3Gw", "Errors.IDMissing"), + }, + }, + { + "not deactivated / exists, error", + fields{ + eventstore: expectEventstore( + expectFilter(), + ), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + id: "id1", + }, + res{ + err: zerrors.ThrowPreconditionFailed(nil, "COMMA-DGzh5", "Errors.UserSchema.NotInactive"), + }, + }, + { + "reactivate ok", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schema.NewCreatedEvent( + context.Background(), + &schema.NewAggregate("id1", "instanceID").Aggregate, + "type", + json.RawMessage(`{}`), + []domain.AuthenticatorType{domain.AuthenticatorTypeUsername}, + ), + ), + eventFromEventPusher( + schema.NewDeactivatedEvent( + context.Background(), + &schema.NewAggregate("id1", "instanceID").Aggregate, + ), + ), + ), + expectPush( + schema.NewReactivatedEvent( + context.Background(), + &schema.NewAggregate("id1", "instanceID").Aggregate, + ), + ), + ), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + id: "id1", + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "instanceID", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore(t), + } + got, err := c.ReactivateUserSchema(tt.args.ctx, tt.args.id, tt.args.resourceOwner) + assert.ErrorIs(t, err, tt.res.err) + assert.Equal(t, tt.res.details, got) + }) + } +} + +func TestCommands_DeleteUserSchema(t *testing.T) { + type fields struct { + eventstore func(t *testing.T) *eventstore.Eventstore + } + type args struct { + ctx context.Context + id string + resourceOwner string + } + type res struct { + details *domain.ObjectDetails + err error + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + "missing id, error", + fields{ + eventstore: expectEventstore(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + id: "", + }, + res{ + err: zerrors.ThrowInvalidArgument(nil, "COMMA-E22gg", "Errors.IDMissing"), + }, + }, + { + "not exists, error", + fields{ + eventstore: expectEventstore( + expectFilter(), + ), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + id: "id1", + }, + res{ + err: zerrors.ThrowPreconditionFailed(nil, "COMMA-Grg41", "Errors.UserSchema.NotExists"), + }, + }, + { + "delete ok", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schema.NewCreatedEvent( + context.Background(), + &schema.NewAggregate("id1", "instanceID").Aggregate, + "type", + json.RawMessage(`{}`), + []domain.AuthenticatorType{domain.AuthenticatorTypeUsername}, + ), + ), + ), + expectPush( + schema.NewDeletedEvent( + context.Background(), + &schema.NewAggregate("id1", "instanceID").Aggregate, + "type", + ), + ), + ), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + id: "id1", + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "instanceID", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore(t), + } + got, err := c.DeleteUserSchema(tt.args.ctx, tt.args.id, tt.args.resourceOwner) + assert.ErrorIs(t, err, tt.res.err) + assert.Equal(t, tt.res.details, got) + }) + } +} diff --git a/internal/domain/schema/permission.go b/internal/domain/schema/permission.go new file mode 100644 index 0000000000..deb33ab14c --- /dev/null +++ b/internal/domain/schema/permission.go @@ -0,0 +1,120 @@ +package schema + +import ( + _ "embed" + + "github.com/santhosh-tekuri/jsonschema/v5" + + "github.com/zitadel/zitadel/internal/zerrors" +) + +var ( + //go:embed permission.schema.v1.json + permissionJSON string + + permissionSchema = jsonschema.MustCompileString(PermissionSchemaID, permissionJSON) +) + +const ( + PermissionSchemaID = "urn:zitadel:schema:permission-schema:v1" + PermissionProperty = "urn:zitadel:schema:permission" +) + +type role int32 + +const ( + roleUnspecified role = iota + roleSelf + roleOwner +) + +type permissionExtension struct { + role role +} + +// Compile implements the [jsonschema.ExtCompiler] interface. +// It parses the permission schema extension / annotation of the passed field. +func (c permissionExtension) Compile(ctx jsonschema.CompilerContext, m map[string]interface{}) (_ jsonschema.ExtSchema, err error) { + perm, ok := m[PermissionProperty] + if !ok { + return nil, nil + } + p, ok := perm.(map[string]interface{}) + if !ok { + return nil, zerrors.ThrowInvalidArgument(nil, "SCHEMA-WR5gs", "invalid permission") + } + perms := new(permissions) + for key, value := range p { + switch key { + case "self": + perms.self, err = mapPermission(value) + if err != nil { + return + } + case "owner": + perms.owner, err = mapPermission(value) + if err != nil { + return + } + default: + return nil, zerrors.ThrowInvalidArgument(nil, "SCHEMA-GFjio", "invalid permission role") + } + } + return permissionExtensionConfig{c.role, perms}, nil +} + +type permissionExtensionConfig struct { + role role + permissions *permissions +} + +// Validate implements the [jsonschema.ExtSchema] interface. +// It validates the fields of the json instance according to the permission schema. +func (s permissionExtensionConfig) Validate(ctx jsonschema.ValidationContext, v interface{}) error { + switch s.role { + case roleSelf: + if s.permissions.self == nil || !s.permissions.self.write { + return ctx.Error("permission", "missing required permission") + } + return nil + case roleOwner: + if s.permissions.owner == nil || !s.permissions.owner.write { + return ctx.Error("permission", "missing required permission") + } + return nil + case roleUnspecified: + fallthrough + default: + return ctx.Error("permission", "missing required permission") + } +} + +func mapPermission(value any) (*permission, error) { + p := new(permission) + switch v := value.(type) { + case string: + for _, s := range v { + switch s { + case 'r': + p.read = true + case 'w': + p.write = true + default: + return nil, zerrors.ThrowInvalidArgumentf(nil, "SCHEMA-EZ5zjh", "invalid permission pattern: `%s` in (%s)", string(s), v) + } + } + return p, nil + default: + return nil, zerrors.ThrowInvalidArgumentf(nil, "SCHEMA-E5h31", "invalid permission type %T (%v)", v, v) + } +} + +type permissions struct { + self *permission + owner *permission +} + +type permission struct { + read bool + write bool +} diff --git a/internal/domain/schema/permission.schema.v1.json b/internal/domain/schema/permission.schema.v1.json new file mode 100644 index 0000000000..a3fcfdb752 --- /dev/null +++ b/internal/domain/schema/permission.schema.v1.json @@ -0,0 +1,28 @@ +{ + "$id": "urn:zitadel:schema:permission-schema:v1", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "urn:zitadel:schema:property-permission": { + "oneOf": [ + { + "type": "string", + "pattern": "^[rw]$" + } + ] + } + }, + "properties": { + "urn:zitadel:schema:permission": { + "type": "object", + "additionalProperties": false, + "properties": { + "owner": { + "$ref": "#/$defs/urn:zitadel:schema:property-permission" + }, + "self": { + "$ref": "#/$defs/urn:zitadel:schema:property-permission" + } + } + } + } +} \ No newline at end of file diff --git a/internal/domain/schema/permission_test.go b/internal/domain/schema/permission_test.go new file mode 100644 index 0000000000..b0799384bd --- /dev/null +++ b/internal/domain/schema/permission_test.go @@ -0,0 +1,253 @@ +package schema + +import ( + _ "embed" + "encoding/json" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/zitadel/zitadel/internal/zerrors" +) + +func TestPermissionExtension(t *testing.T) { + type args struct { + role role + schema string + instance string + } + type want struct { + compilationErr error + validationErr bool + } + tests := []struct { + name string + args args + want want + }{ + { + "invalid permission, compilation err", + args{ + schema: `{ + "type": "object", + "properties": { + "name": { + "type": "string", + "urn:zitadel:schema:permission": "read" + } + } + }`, + }, + want{ + compilationErr: zerrors.ThrowInvalidArgument(nil, "SCHEMA-WR5gs", "invalid permission"), + }, + }, + { + "invalid permission string, compilation err", + args{ + schema: `{ + "type": "object", + "properties": { + "name": { + "type": "string", + "urn:zitadel:schema:permission": { + "self": "read" + } + } + } + }`, + }, + want{ + compilationErr: zerrors.ThrowInvalidArgument(nil, "SCHEMA-EZ5zjh", "invalid permission pattern: `e` in (read)"), + }, + }, + { + "invalid permission type, compilation err", + args{ + schema: `{ + "type": "object", + "properties": { + "name": { + "type": "string", + "urn:zitadel:schema:permission": { + "owner": true + } + } + } + }`, + }, + want{ + compilationErr: zerrors.ThrowInvalidArgument(nil, "SCHEMA-E5h31", "invalid permission type bool (true)"), + }, + }, + { + "invalid role, compilation err", + args{ + schema: `{ + "type": "object", + "properties": { + "name": { + "type": "string", + "urn:zitadel:schema:permission": { + "IAM_OWNER": "rw" + } + } + } + }`, + }, + want{ + compilationErr: zerrors.ThrowInvalidArgument(nil, "SCHEMA-GFjio", "invalid permission role"), + }, + }, + { + "invalid permission self, validation err", + args{ + role: roleSelf, + schema: `{ + "type": "object", + "properties": { + "name": { + "type": "string", + "urn:zitadel:schema:permission": { + "owner": "rw", + "self": "r" + } + } + } + }`, + instance: `{ "name": "test"}`, + }, + want{ + validationErr: true, + }, + }, + { + "invalid permission owner, validation err", + args{ + role: roleOwner, + schema: `{ + "type": "object", + "properties": { + "name": { + "type": "string", + "urn:zitadel:schema:permission": { + "owner": "r", + "self": "r" + } + } + } + }`, + instance: `{ "name": "test"}`, + }, + want{ + validationErr: true, + }, + }, + { + "valid permission self, ok", + args{ + role: roleSelf, + schema: `{ + "type": "object", + "properties": { + "name": { + "type": "string", + "urn:zitadel:schema:permission": { + "owner": "r", + "self": "rw" + } + } + } + }`, + instance: `{ "name": "test"}`, + }, + want{ + validationErr: false, + }, + }, + { + "valid permission owner, ok", + args{ + role: roleOwner, + schema: `{ + "type": "object", + "properties": { + "name": { + "type": "string", + "urn:zitadel:schema:permission": { + "owner": "rw", + "self": "r" + } + } + } + }`, + instance: `{ "name": "test"}`, + }, + want{ + validationErr: false, + }, + }, + { + "no role, validation err", + args{ + role: roleUnspecified, + schema: `{ + "type": "object", + "properties": { + "name": { + "type": "string", + "urn:zitadel:schema:permission": { + "owner": "rw", + "self": "rw" + } + } + } + }`, + instance: `{ "name": "test"}`, + }, + want{ + validationErr: true, + }, + }, + { + "no permission required, ok", + args{ + role: roleSelf, + schema: `{ + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + }`, + instance: `{ "name": "test"}`, + }, + want{ + validationErr: false, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + schema, err := NewSchema(tt.args.role, strings.NewReader(tt.args.schema)) + require.ErrorIs(t, err, tt.want.compilationErr) + if tt.want.compilationErr != nil { + return + } + + var v interface{} + err = json.Unmarshal([]byte(tt.args.instance), &v) + require.NoError(t, err) + + err = schema.Validate(v) + if tt.want.validationErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + }) + } +} diff --git a/internal/domain/schema/schema.go b/internal/domain/schema/schema.go new file mode 100644 index 0000000000..a8eee88de6 --- /dev/null +++ b/internal/domain/schema/schema.go @@ -0,0 +1,41 @@ +package schema + +import ( + _ "embed" + "io" + "strings" + + "github.com/santhosh-tekuri/jsonschema/v5" + + "github.com/zitadel/zitadel/internal/zerrors" +) + +var ( + //go:embed zitadel.schema.v1.json + zitadelJSON string +) + +const ( + MetaSchemaID = "urn:zitadel:schema:v1" +) + +func NewSchema(role role, r io.Reader) (*jsonschema.Schema, error) { + c := jsonschema.NewCompiler() + if err := c.AddResource(PermissionSchemaID, strings.NewReader(permissionJSON)); err != nil { + return nil, err + } + if err := c.AddResource(MetaSchemaID, strings.NewReader(zitadelJSON)); err != nil { + return nil, err + } + c.RegisterExtension(PermissionSchemaID, permissionSchema, permissionExtension{ + role, + }) + if err := c.AddResource("schema.json", r); err != nil { + return nil, zerrors.ThrowInvalidArgument(err, "COMMA-Frh42", "Errors.UserSchema.Schema.Invalid") + } + schema, err := c.Compile("schema.json") + if err != nil { + return nil, zerrors.ThrowInvalidArgument(err, "COMMA-W21tg", "Errors.UserSchema.Schema.Invalid") + } + return schema, nil +} diff --git a/internal/domain/schema/zitadel.schema.v1.json b/internal/domain/schema/zitadel.schema.v1.json new file mode 100644 index 0000000000..105fd158f7 --- /dev/null +++ b/internal/domain/schema/zitadel.schema.v1.json @@ -0,0 +1,13 @@ +{ + "$id": "urn:zitadel:schema:v1", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "allOf": [ + { + "$ref": "https://json-schema.org/draft/2020-12/schema" + }, + { + "$ref": "urn:zitadel:schema:permission-schema:v1" + } + ] +} \ No newline at end of file diff --git a/internal/domain/user_schema.go b/internal/domain/user_schema.go new file mode 100644 index 0000000000..c6f2bddb55 --- /dev/null +++ b/internal/domain/user_schema.go @@ -0,0 +1,26 @@ +package domain + +type UserSchemaState int32 + +const ( + UserSchemaStateUnspecified UserSchemaState = iota + UserSchemaStateActive + UserSchemaStateInactive + UserSchemaStateDeleted + userSchemaStateCount +) + +type AuthenticatorType int32 + +const ( + AuthenticatorTypeUnspecified AuthenticatorType = iota + AuthenticatorTypeUsername + AuthenticatorTypePassword + AuthenticatorTypeWebAuthN + AuthenticatorTypeTOTP + AuthenticatorTypeOTPEmail + AuthenticatorTypeOTPSMS + AuthenticatorTypeAuthenticationKey + AuthenticatorTypeIdentityProvider + authenticatorTypeCount +) diff --git a/internal/feature/feature.go b/internal/feature/feature.go index 0c277cb5a9..e85413efb1 100644 --- a/internal/feature/feature.go +++ b/internal/feature/feature.go @@ -8,6 +8,7 @@ const ( KeyLoginDefaultOrg KeyTriggerIntrospectionProjections KeyLegacyIntrospection + KeyUserSchema ) //go:generate enumer -type Level -transform snake -trimprefix Level @@ -27,4 +28,5 @@ type Features struct { LoginDefaultOrg bool `json:"login_default_org,omitempty"` TriggerIntrospectionProjections bool `json:"trigger_introspection_projections,omitempty"` LegacyIntrospection bool `json:"legacy_introspection,omitempty"` + UserSchema bool `json:"user_schema,omitempty"` } diff --git a/internal/feature/key_enumer.go b/internal/feature/key_enumer.go index 74e0af99d4..eaa4340647 100644 --- a/internal/feature/key_enumer.go +++ b/internal/feature/key_enumer.go @@ -7,11 +7,11 @@ import ( "strings" ) -const _KeyName = "unspecifiedlogin_default_orgtrigger_introspection_projectionslegacy_introspection" +const _KeyName = "unspecifiedlogin_default_orgtrigger_introspection_projectionslegacy_introspectionuser_schema" -var _KeyIndex = [...]uint8{0, 11, 28, 61, 81} +var _KeyIndex = [...]uint8{0, 11, 28, 61, 81, 92} -const _KeyLowerName = "unspecifiedlogin_default_orgtrigger_introspection_projectionslegacy_introspection" +const _KeyLowerName = "unspecifiedlogin_default_orgtrigger_introspection_projectionslegacy_introspectionuser_schema" func (i Key) String() string { if i < 0 || i >= Key(len(_KeyIndex)-1) { @@ -28,9 +28,10 @@ func _KeyNoOp() { _ = x[KeyLoginDefaultOrg-(1)] _ = x[KeyTriggerIntrospectionProjections-(2)] _ = x[KeyLegacyIntrospection-(3)] + _ = x[KeyUserSchema-(4)] } -var _KeyValues = []Key{KeyUnspecified, KeyLoginDefaultOrg, KeyTriggerIntrospectionProjections, KeyLegacyIntrospection} +var _KeyValues = []Key{KeyUnspecified, KeyLoginDefaultOrg, KeyTriggerIntrospectionProjections, KeyLegacyIntrospection, KeyUserSchema} var _KeyNameToValueMap = map[string]Key{ _KeyName[0:11]: KeyUnspecified, @@ -41,6 +42,8 @@ var _KeyNameToValueMap = map[string]Key{ _KeyLowerName[28:61]: KeyTriggerIntrospectionProjections, _KeyName[61:81]: KeyLegacyIntrospection, _KeyLowerName[61:81]: KeyLegacyIntrospection, + _KeyName[81:92]: KeyUserSchema, + _KeyLowerName[81:92]: KeyUserSchema, } var _KeyNames = []string{ @@ -48,6 +51,7 @@ var _KeyNames = []string{ _KeyName[11:28], _KeyName[28:61], _KeyName[61:81], + _KeyName[81:92], } // KeyString retrieves an enum value from the enum constants string name. diff --git a/internal/integration/client.go b/internal/integration/client.go index 12a1b0c846..7d97e2a188 100644 --- a/internal/integration/client.go +++ b/internal/integration/client.go @@ -16,6 +16,7 @@ import ( "golang.org/x/text/language" "google.golang.org/grpc" "google.golang.org/protobuf/types/known/durationpb" + "google.golang.org/protobuf/types/known/structpb" "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/command" @@ -37,38 +38,41 @@ import ( settings "github.com/zitadel/zitadel/pkg/grpc/settings/v2beta" "github.com/zitadel/zitadel/pkg/grpc/system" user_pb "github.com/zitadel/zitadel/pkg/grpc/user" + schema "github.com/zitadel/zitadel/pkg/grpc/user/schema/v3alpha" user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" ) type Client struct { - CC *grpc.ClientConn - Admin admin.AdminServiceClient - Mgmt mgmt.ManagementServiceClient - Auth auth.AuthServiceClient - UserV2 user.UserServiceClient - SessionV2 session.SessionServiceClient - SettingsV2 settings.SettingsServiceClient - OIDCv2 oidc_pb.OIDCServiceClient - OrgV2 organisation.OrganizationServiceClient - System system.SystemServiceClient - ExecutionV3 execution.ExecutionServiceClient - FeatureV2 feature.FeatureServiceClient + CC *grpc.ClientConn + Admin admin.AdminServiceClient + Mgmt mgmt.ManagementServiceClient + Auth auth.AuthServiceClient + UserV2 user.UserServiceClient + SessionV2 session.SessionServiceClient + SettingsV2 settings.SettingsServiceClient + OIDCv2 oidc_pb.OIDCServiceClient + OrgV2 organisation.OrganizationServiceClient + System system.SystemServiceClient + ExecutionV3 execution.ExecutionServiceClient + FeatureV2 feature.FeatureServiceClient + UserSchemaV3 schema.UserSchemaServiceClient } func newClient(cc *grpc.ClientConn) Client { return Client{ - CC: cc, - Admin: admin.NewAdminServiceClient(cc), - Mgmt: mgmt.NewManagementServiceClient(cc), - Auth: auth.NewAuthServiceClient(cc), - UserV2: user.NewUserServiceClient(cc), - SessionV2: session.NewSessionServiceClient(cc), - SettingsV2: settings.NewSettingsServiceClient(cc), - OIDCv2: oidc_pb.NewOIDCServiceClient(cc), - OrgV2: organisation.NewOrganizationServiceClient(cc), - System: system.NewSystemServiceClient(cc), - ExecutionV3: execution.NewExecutionServiceClient(cc), - FeatureV2: feature.NewFeatureServiceClient(cc), + CC: cc, + Admin: admin.NewAdminServiceClient(cc), + Mgmt: mgmt.NewManagementServiceClient(cc), + Auth: auth.NewAuthServiceClient(cc), + UserV2: user.NewUserServiceClient(cc), + SessionV2: session.NewSessionServiceClient(cc), + SettingsV2: settings.NewSettingsServiceClient(cc), + OIDCv2: oidc_pb.NewOIDCServiceClient(cc), + OrgV2: organisation.NewOrganizationServiceClient(cc), + System: system.NewSystemServiceClient(cc), + ExecutionV3: execution.NewExecutionServiceClient(cc), + FeatureV2: feature.NewFeatureServiceClient(cc), + UserSchemaV3: schema.NewUserSchemaServiceClient(cc), } } @@ -540,3 +544,21 @@ func (s *Tester) SetExecution(ctx context.Context, t *testing.T, cond *execution require.NoError(t, err) return target } + +func (s *Tester) CreateUserSchema(ctx context.Context, t *testing.T) *schema.CreateUserSchemaResponse { + userSchema := new(structpb.Struct) + err := userSchema.UnmarshalJSON([]byte(`{ + "$schema": "urn:zitadel:schema:v1", + "type": "object", + "properties": {} + }`)) + require.NoError(t, err) + target, err := s.Client.UserSchemaV3.CreateUserSchema(ctx, &schema.CreateUserSchemaRequest{ + Type: fmt.Sprint(time.Now().UnixNano() + 1), + DataType: &schema.CreateUserSchemaRequest_Schema{ + Schema: userSchema, + }, + }) + require.NoError(t, err) + return target +} diff --git a/internal/query/instance_features.go b/internal/query/instance_features.go index b447df4a8a..62e67b46b0 100644 --- a/internal/query/instance_features.go +++ b/internal/query/instance_features.go @@ -11,6 +11,7 @@ type InstanceFeatures struct { LoginDefaultOrg FeatureSource[bool] TriggerIntrospectionProjections FeatureSource[bool] LegacyIntrospection FeatureSource[bool] + UserSchema FeatureSource[bool] } func (q *Queries) GetInstanceFeatures(ctx context.Context, cascade bool) (_ *InstanceFeatures, err error) { diff --git a/internal/query/instance_features_model.go b/internal/query/instance_features_model.go index 4d6a043d8c..ddbd3529bd 100644 --- a/internal/query/instance_features_model.go +++ b/internal/query/instance_features_model.go @@ -60,6 +60,7 @@ func (m *InstanceFeaturesReadModel) Query() *eventstore.SearchQueryBuilder { feature_v2.InstanceLoginDefaultOrgEventType, feature_v2.InstanceTriggerIntrospectionProjectionsEventType, feature_v2.InstanceLegacyIntrospectionEventType, + feature_v2.InstanceUserSchemaEventType, ). Builder().ResourceOwner(m.ResourceOwner) } @@ -71,6 +72,7 @@ func (m *InstanceFeaturesReadModel) reduceReset() { m.instance.LoginDefaultOrg = FeatureSource[bool]{} m.instance.TriggerIntrospectionProjections = FeatureSource[bool]{} m.instance.LegacyIntrospection = FeatureSource[bool]{} + m.instance.UserSchema = FeatureSource[bool]{} } func (m *InstanceFeaturesReadModel) populateFromSystem() bool { @@ -80,6 +82,7 @@ func (m *InstanceFeaturesReadModel) populateFromSystem() bool { m.instance.LoginDefaultOrg = m.system.LoginDefaultOrg m.instance.TriggerIntrospectionProjections = m.system.TriggerIntrospectionProjections m.instance.LegacyIntrospection = m.system.LegacyIntrospection + m.instance.UserSchema = m.system.UserSchema return true } @@ -99,6 +102,8 @@ func (m *InstanceFeaturesReadModel) reduceBoolFeature(event *feature_v2.SetEvent dst = &m.instance.TriggerIntrospectionProjections case feature.KeyLegacyIntrospection: dst = &m.instance.LegacyIntrospection + case feature.KeyUserSchema: + dst = &m.instance.UserSchema } *dst = FeatureSource[bool]{ Level: level, diff --git a/internal/query/instance_features_test.go b/internal/query/instance_features_test.go index 142c72d621..010a4d42e4 100644 --- a/internal/query/instance_features_test.go +++ b/internal/query/instance_features_test.go @@ -101,6 +101,10 @@ func TestQueries_GetInstanceFeatures(t *testing.T) { ctx, aggregate, feature_v2.InstanceLegacyIntrospectionEventType, false, )), + eventFromEventPusher(feature_v2.NewSetEvent[bool]( + ctx, aggregate, + feature_v2.InstanceUserSchemaEventType, false, + )), ), ), args: args{true}, @@ -120,6 +124,10 @@ func TestQueries_GetInstanceFeatures(t *testing.T) { Level: feature.LevelInstance, Value: false, }, + UserSchema: FeatureSource[bool]{ + Level: feature.LevelInstance, + Value: false, + }, }, }, { @@ -142,6 +150,10 @@ func TestQueries_GetInstanceFeatures(t *testing.T) { ctx, aggregate, feature_v2.InstanceLegacyIntrospectionEventType, false, )), + eventFromEventPusher(feature_v2.NewSetEvent[bool]( + ctx, aggregate, + feature_v2.InstanceUserSchemaEventType, false, + )), eventFromEventPusher(feature_v2.NewResetEvent( ctx, aggregate, feature_v2.InstanceResetEventType, @@ -169,6 +181,10 @@ func TestQueries_GetInstanceFeatures(t *testing.T) { Level: feature.LevelUnspecified, Value: false, }, + UserSchema: FeatureSource[bool]{ + Level: feature.LevelUnspecified, + Value: false, + }, }, }, { @@ -187,6 +203,10 @@ func TestQueries_GetInstanceFeatures(t *testing.T) { ctx, aggregate, feature_v2.InstanceLegacyIntrospectionEventType, false, )), + eventFromEventPusher(feature_v2.NewSetEvent[bool]( + ctx, aggregate, + feature_v2.InstanceUserSchemaEventType, false, + )), eventFromEventPusher(feature_v2.NewResetEvent( ctx, aggregate, feature_v2.InstanceResetEventType, @@ -214,6 +234,10 @@ func TestQueries_GetInstanceFeatures(t *testing.T) { Level: feature.LevelUnspecified, Value: false, }, + UserSchema: FeatureSource[bool]{ + Level: feature.LevelUnspecified, + Value: false, + }, }, }, } diff --git a/internal/query/projection/instance_features.go b/internal/query/projection/instance_features.go index fae8824429..cfe8037786 100644 --- a/internal/query/projection/instance_features.go +++ b/internal/query/projection/instance_features.go @@ -71,6 +71,10 @@ func (*instanceFeatureProjection) Reducers() []handler.AggregateReducer { Event: feature_v2.InstanceLegacyIntrospectionEventType, Reduce: reduceInstanceSetFeature[bool], }, + { + Event: feature_v2.InstanceUserSchemaEventType, + Reduce: reduceInstanceSetFeature[bool], + }, { Event: instance.InstanceRemovedEventType, Reduce: reduceInstanceRemovedHelper(InstanceDomainInstanceIDCol), diff --git a/internal/query/projection/system_features.go b/internal/query/projection/system_features.go index 3d3f6bd1cc..7eccfa7938 100644 --- a/internal/query/projection/system_features.go +++ b/internal/query/projection/system_features.go @@ -63,6 +63,10 @@ func (*systemFeatureProjection) Reducers() []handler.AggregateReducer { Event: feature_v2.SystemLegacyIntrospectionEventType, Reduce: reduceSystemSetFeature[bool], }, + { + Event: feature_v2.SystemUserSchemaEventType, + Reduce: reduceSystemSetFeature[bool], + }, }, }} } diff --git a/internal/query/system_features.go b/internal/query/system_features.go index 8ed1cfca5c..6f87d7c69d 100644 --- a/internal/query/system_features.go +++ b/internal/query/system_features.go @@ -18,6 +18,7 @@ type SystemFeatures struct { LoginDefaultOrg FeatureSource[bool] TriggerIntrospectionProjections FeatureSource[bool] LegacyIntrospection FeatureSource[bool] + UserSchema FeatureSource[bool] } func (q *Queries) GetSystemFeatures(ctx context.Context) (_ *SystemFeatures, err error) { diff --git a/internal/query/system_features_model.go b/internal/query/system_features_model.go index 87fa8c93ee..d5e7b32632 100644 --- a/internal/query/system_features_model.go +++ b/internal/query/system_features_model.go @@ -48,6 +48,7 @@ func (m *SystemFeaturesReadModel) Query() *eventstore.SearchQueryBuilder { feature_v2.SystemLoginDefaultOrgEventType, feature_v2.SystemTriggerIntrospectionProjectionsEventType, feature_v2.SystemLegacyIntrospectionEventType, + feature_v2.SystemUserSchemaEventType, ). Builder().ResourceOwner(m.ResourceOwner) } @@ -72,6 +73,8 @@ func (m *SystemFeaturesReadModel) reduceBoolFeature(event *feature_v2.SetEvent[b dst = &m.system.TriggerIntrospectionProjections case feature.KeyLegacyIntrospection: dst = &m.system.LegacyIntrospection + case feature.KeyUserSchema: + dst = &m.system.UserSchema } *dst = FeatureSource[bool]{ diff --git a/internal/query/system_features_test.go b/internal/query/system_features_test.go index 051e0b6892..f1dc184158 100644 --- a/internal/query/system_features_test.go +++ b/internal/query/system_features_test.go @@ -57,6 +57,10 @@ func TestQueries_GetSystemFeatures(t *testing.T) { context.Background(), aggregate, feature_v2.SystemLegacyIntrospectionEventType, false, )), + eventFromEventPusher(feature_v2.NewSetEvent[bool]( + context.Background(), aggregate, + feature_v2.SystemUserSchemaEventType, false, + )), ), ), want: &SystemFeatures{ @@ -75,6 +79,10 @@ func TestQueries_GetSystemFeatures(t *testing.T) { Level: feature.LevelSystem, Value: false, }, + UserSchema: FeatureSource[bool]{ + Level: feature.LevelSystem, + Value: false, + }, }, }, { @@ -93,6 +101,10 @@ func TestQueries_GetSystemFeatures(t *testing.T) { context.Background(), aggregate, feature_v2.SystemLegacyIntrospectionEventType, false, )), + eventFromEventPusher(feature_v2.NewSetEvent[bool]( + context.Background(), aggregate, + feature_v2.SystemUserSchemaEventType, false, + )), eventFromEventPusher(feature_v2.NewResetEvent( context.Background(), aggregate, feature_v2.SystemResetEventType, @@ -119,6 +131,10 @@ func TestQueries_GetSystemFeatures(t *testing.T) { Level: feature.LevelUnspecified, Value: false, }, + UserSchema: FeatureSource[bool]{ + Level: feature.LevelUnspecified, + Value: false, + }, }, }, { @@ -137,6 +153,10 @@ func TestQueries_GetSystemFeatures(t *testing.T) { context.Background(), aggregate, feature_v2.SystemLegacyIntrospectionEventType, false, )), + eventFromEventPusher(feature_v2.NewSetEvent[bool]( + context.Background(), aggregate, + feature_v2.SystemUserSchemaEventType, false, + )), eventFromEventPusher(feature_v2.NewResetEvent( context.Background(), aggregate, feature_v2.SystemResetEventType, @@ -163,6 +183,10 @@ func TestQueries_GetSystemFeatures(t *testing.T) { Level: feature.LevelUnspecified, Value: false, }, + UserSchema: FeatureSource[bool]{ + Level: feature.LevelUnspecified, + Value: false, + }, }, }, } diff --git a/internal/repository/feature/feature_v2/eventstore.go b/internal/repository/feature/feature_v2/eventstore.go index 4b0c1d2938..c1d2c6122d 100644 --- a/internal/repository/feature/feature_v2/eventstore.go +++ b/internal/repository/feature/feature_v2/eventstore.go @@ -9,8 +9,10 @@ func init() { eventstore.RegisterFilterEventMapper(AggregateType, SystemLoginDefaultOrgEventType, eventstore.GenericEventMapper[SetEvent[bool]]) eventstore.RegisterFilterEventMapper(AggregateType, SystemTriggerIntrospectionProjectionsEventType, eventstore.GenericEventMapper[SetEvent[bool]]) eventstore.RegisterFilterEventMapper(AggregateType, SystemLegacyIntrospectionEventType, eventstore.GenericEventMapper[SetEvent[bool]]) + eventstore.RegisterFilterEventMapper(AggregateType, SystemUserSchemaEventType, eventstore.GenericEventMapper[SetEvent[bool]]) eventstore.RegisterFilterEventMapper(AggregateType, InstanceResetEventType, eventstore.GenericEventMapper[ResetEvent]) eventstore.RegisterFilterEventMapper(AggregateType, InstanceLoginDefaultOrgEventType, eventstore.GenericEventMapper[SetEvent[bool]]) eventstore.RegisterFilterEventMapper(AggregateType, InstanceTriggerIntrospectionProjectionsEventType, eventstore.GenericEventMapper[SetEvent[bool]]) eventstore.RegisterFilterEventMapper(AggregateType, InstanceLegacyIntrospectionEventType, eventstore.GenericEventMapper[SetEvent[bool]]) + eventstore.RegisterFilterEventMapper(AggregateType, InstanceUserSchemaEventType, eventstore.GenericEventMapper[SetEvent[bool]]) } diff --git a/internal/repository/feature/feature_v2/feature.go b/internal/repository/feature/feature_v2/feature.go index 7419ecf5b6..314abcdb4e 100644 --- a/internal/repository/feature/feature_v2/feature.go +++ b/internal/repository/feature/feature_v2/feature.go @@ -15,11 +15,13 @@ var ( SystemLoginDefaultOrgEventType = setEventTypeFromFeature(feature.LevelSystem, feature.KeyLoginDefaultOrg) SystemTriggerIntrospectionProjectionsEventType = setEventTypeFromFeature(feature.LevelSystem, feature.KeyTriggerIntrospectionProjections) SystemLegacyIntrospectionEventType = setEventTypeFromFeature(feature.LevelSystem, feature.KeyLegacyIntrospection) + SystemUserSchemaEventType = setEventTypeFromFeature(feature.LevelSystem, feature.KeyUserSchema) InstanceResetEventType = resetEventTypeFromFeature(feature.LevelInstance) InstanceLoginDefaultOrgEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyLoginDefaultOrg) InstanceTriggerIntrospectionProjectionsEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyTriggerIntrospectionProjections) InstanceLegacyIntrospectionEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyLegacyIntrospection) + InstanceUserSchemaEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyUserSchema) ) const ( diff --git a/internal/repository/user/schema/aggregate.go b/internal/repository/user/schema/aggregate.go new file mode 100644 index 0000000000..b5e153a9b9 --- /dev/null +++ b/internal/repository/user/schema/aggregate.go @@ -0,0 +1,25 @@ +package schema + +import ( + "github.com/zitadel/zitadel/internal/eventstore" +) + +const ( + AggregateType = "user_schema" + AggregateVersion = "v1" +) + +type Aggregate struct { + eventstore.Aggregate +} + +func NewAggregate(id, resourceOwner string) *Aggregate { + return &Aggregate{ + Aggregate: eventstore.Aggregate{ + Type: AggregateType, + Version: AggregateVersion, + ID: id, + ResourceOwner: resourceOwner, + }, + } +} diff --git a/internal/repository/user/schema/eventstore.go b/internal/repository/user/schema/eventstore.go new file mode 100644 index 0000000000..375ecfe601 --- /dev/null +++ b/internal/repository/user/schema/eventstore.go @@ -0,0 +1,11 @@ +package schema + +import "github.com/zitadel/zitadel/internal/eventstore" + +func init() { + eventstore.RegisterFilterEventMapper(AggregateType, CreatedType, eventstore.GenericEventMapper[CreatedEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, UpdatedType, eventstore.GenericEventMapper[UpdatedEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, DeactivatedType, eventstore.GenericEventMapper[DeactivatedEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, ReactivatedType, eventstore.GenericEventMapper[ReactivatedEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, DeletedType, eventstore.GenericEventMapper[DeletedEvent]) +} diff --git a/internal/repository/user/schema/schema.go b/internal/repository/user/schema/schema.go new file mode 100644 index 0000000000..b626ee4c7d --- /dev/null +++ b/internal/repository/user/schema/schema.go @@ -0,0 +1,233 @@ +package schema + +import ( + "context" + "encoding/json" + + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore" +) + +const ( + eventPrefix = "user_schema." + CreatedType = eventPrefix + "created" + UpdatedType = eventPrefix + "updated" + DeactivatedType = eventPrefix + "deactivated" + ReactivatedType = eventPrefix + "reactivated" + DeletedType = eventPrefix + "deleted" + + uniqueSchemaType = "user_schema_type" +) + +func NewAddSchemaTypeUniqueConstraint(schemaType string) *eventstore.UniqueConstraint { + return eventstore.NewAddEventUniqueConstraint( + uniqueSchemaType, + schemaType, + "Errors.UserSchema.Type.AlreadyExists") +} + +func NewRemoveSchemaTypeUniqueConstraint(schemaType string) *eventstore.UniqueConstraint { + return eventstore.NewRemoveUniqueConstraint( + uniqueSchemaType, + schemaType, + ) +} + +type CreatedEvent struct { + *eventstore.BaseEvent `json:"-"` + + SchemaType string `json:"schemaType"` + Schema json.RawMessage `json:"schema,omitempty"` + PossibleAuthenticators []domain.AuthenticatorType `json:"possibleAuthenticators,omitempty"` +} + +func (e *CreatedEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = event +} + +func (e *CreatedEvent) Payload() interface{} { + return e +} + +func (e *CreatedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { + return []*eventstore.UniqueConstraint{NewAddSchemaTypeUniqueConstraint(e.SchemaType)} +} + +func NewCreatedEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, + + schemaType string, + schema json.RawMessage, + possibleAuthenticators []domain.AuthenticatorType, +) *CreatedEvent { + return &CreatedEvent{ + BaseEvent: eventstore.NewBaseEventForPush( + ctx, + aggregate, + CreatedType, + ), + SchemaType: schemaType, + Schema: schema, + PossibleAuthenticators: possibleAuthenticators, + } +} + +type UpdatedEvent struct { + *eventstore.BaseEvent `json:"-"` + + SchemaType *string `json:"schemaType,omitempty"` + Schema json.RawMessage `json:"schema,omitempty"` + PossibleAuthenticators []domain.AuthenticatorType `json:"possibleAuthenticators,omitempty"` + oldSchemaType string +} + +func (e *UpdatedEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = event +} + +func (e *UpdatedEvent) Payload() interface{} { + return e +} + +func (e *UpdatedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { + if e.oldSchemaType == "" { + return nil + } + return []*eventstore.UniqueConstraint{ + NewRemoveSchemaTypeUniqueConstraint(e.oldSchemaType), + NewAddSchemaTypeUniqueConstraint(*e.SchemaType), + } +} + +func NewUpdatedEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, + changes []Changes, +) *UpdatedEvent { + updatedEvent := &UpdatedEvent{ + BaseEvent: eventstore.NewBaseEventForPush( + ctx, + aggregate, + UpdatedType, + ), + } + for _, change := range changes { + change(updatedEvent) + } + return updatedEvent +} + +type Changes func(event *UpdatedEvent) + +func ChangeSchemaType(oldSchemaType, schemaType string) func(event *UpdatedEvent) { + return func(e *UpdatedEvent) { + e.SchemaType = &schemaType + e.oldSchemaType = oldSchemaType + } +} + +func ChangeSchema(schema json.RawMessage) func(event *UpdatedEvent) { + return func(e *UpdatedEvent) { + e.Schema = schema + } +} + +func ChangePossibleAuthenticators(possibleAuthenticators []domain.AuthenticatorType) func(event *UpdatedEvent) { + return func(e *UpdatedEvent) { + e.PossibleAuthenticators = possibleAuthenticators + } +} + +type DeactivatedEvent struct { + *eventstore.BaseEvent `json:"-"` +} + +func (e *DeactivatedEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = event +} + +func (e *DeactivatedEvent) Payload() interface{} { + return e +} + +func (e *DeactivatedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { + return nil +} + +func NewDeactivatedEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, +) *DeactivatedEvent { + return &DeactivatedEvent{ + BaseEvent: eventstore.NewBaseEventForPush( + ctx, + aggregate, + DeactivatedType, + ), + } +} + +type ReactivatedEvent struct { + *eventstore.BaseEvent `json:"-"` +} + +func (e *ReactivatedEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = event +} + +func (e *ReactivatedEvent) Payload() interface{} { + return e +} + +func (e *ReactivatedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { + return nil +} + +func NewReactivatedEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, +) *ReactivatedEvent { + return &ReactivatedEvent{ + BaseEvent: eventstore.NewBaseEventForPush( + ctx, + aggregate, + ReactivatedType, + ), + } +} + +type DeletedEvent struct { + *eventstore.BaseEvent `json:"-"` + + schemaType string +} + +func (e *DeletedEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = event +} + +func (e *DeletedEvent) Payload() interface{} { + return e +} + +func (e *DeletedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { + return []*eventstore.UniqueConstraint{ + NewRemoveSchemaTypeUniqueConstraint(e.schemaType), + } +} + +func NewDeletedEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, + schemaType string, +) *DeletedEvent { + return &DeletedEvent{ + BaseEvent: eventstore.NewBaseEventForPush( + ctx, + aggregate, + DeletedType, + ), + schemaType: schemaType, + } +} diff --git a/internal/static/i18n/bg.yaml b/internal/static/i18n/bg.yaml index 566f4c2076..9e6ca104a0 100644 --- a/internal/static/i18n/bg.yaml +++ b/internal/static/i18n/bg.yaml @@ -563,6 +563,16 @@ Errors: NotFound: Изпълнението не е намерено IncludeNotFound: Включването не е намерено NoTargets: Няма определени цели + UserSchema: + NotEnabled: Функцията „Потребителска схема“ не е активирана + Type: + Missing: Липсва тип потребителска схема + AlreadyExists: Типът потребителска схема вече съществува + Authenticator: + Invalid: Невалиден тип удостоверител + NotActive: Потребителската схема не е активна + NotInactive: Потребителската схема не е неактивна + NotExists: Потребителската схема не съществува AggregateTypes: action: Действие @@ -576,6 +586,7 @@ AggregateTypes: feature: Особеност target: Целта execution: Екзекуция + user_schema: Потребителска схема EventTypes: execution: @@ -1268,6 +1279,12 @@ EventTypes: password: changed: Паролата на SMTP конфигурацията е променена removed: Премахната SMTP конфигурация + user_schema: + created: Създадена е потребителска схема + updated: Потребителската схема е актуализирана + deactivated: Потребителската схема е деактивирана + reactivated: Потребителската схема е активирана отново + deleted: Потребителската схема е изтрита Application: OIDC: UnsupportedVersion: Вашата OIDC версия не се поддържа diff --git a/internal/static/i18n/cs.yaml b/internal/static/i18n/cs.yaml index 619371c334..80d6d31ff8 100644 --- a/internal/static/i18n/cs.yaml +++ b/internal/static/i18n/cs.yaml @@ -543,6 +543,16 @@ Errors: NotFound: Provedení nenalezeno IncludeNotFound: Zahrnout nenalezeno NoTargets: Nejsou definovány žádné cíle + UserSchema: + NotEnabled: Funkce "Uživatelské schéma" není povolena + Type: + Missing: Chybí typ uživatelského schématu + AlreadyExists: Typ uživatelského schématu již existuje + Authenticator: + Invalid: Neplatný typ ověřovače + NotActive: Uživatelské schéma není aktivní + NotInactive: Uživatelské schéma není neaktivní + NotExists: Uživatelské schéma neexistuje AggregateTypes: action: Akce @@ -556,6 +566,7 @@ AggregateTypes: feature: Funkce target: Cíl execution: Provedení + user_schema: Uživatelské schéma EventTypes: execution: @@ -1233,6 +1244,12 @@ EventTypes: password: changed: Heslo konfigurace SMTP změněno removed: Konfigurace SMTP odstraněna + user_schema: + created: Vytvořeno uživatelské schéma + updated: Uživatelské schéma bylo aktualizováno + deactivated: Uživatelské schéma deaktivováno + reactivated: Uživatelské schéma bylo znovu aktivováno + deleted: Uživatelské schéma bylo smazáno Application: OIDC: diff --git a/internal/static/i18n/de.yaml b/internal/static/i18n/de.yaml index 7e2474091b..8387d34be8 100644 --- a/internal/static/i18n/de.yaml +++ b/internal/static/i18n/de.yaml @@ -546,6 +546,16 @@ Errors: NotFound: Ausführung nicht gefunden IncludeNotFound: Einschließen nicht gefunden NoTargets: Keine Ziele definiert + UserSchema: + NotEnabled: Funktion Benutzerschema ist nicht aktiviert + Type: + Missing: Benutzerschematyp fehlt + AlreadyExists: Benutzerschematyp existiert bereits + Authenticator: + Invalid: Ungültiger Authentifizierungstyp + NotActive: Benutzerschema nicht aktiv + NotInactive: Benutzerschema nicht inaktiv + NotExists: Benutzerschema existiert nicht AggregateTypes: action: Action @@ -559,6 +569,7 @@ AggregateTypes: feature: Feature target: Ziel execution: Ausführung + user_schema: Benutzerschema EventTypes: execution: @@ -1236,6 +1247,12 @@ EventTypes: password: changed: Passwort von SMTP Konfiguration geändert removed: SMTP Konfiguration gelöscht + user_schema: + created: Benutzerschema erstellt + updated: Benutzerschema geändert + deactivated: Benutzerschema deaktiviert + reactivated: Benutzerschema reaktiviert + deleted: Benutzerschema gelöscht Application: OIDC: diff --git a/internal/static/i18n/en.yaml b/internal/static/i18n/en.yaml index f7a7ad40cb..e5534b357b 100644 --- a/internal/static/i18n/en.yaml +++ b/internal/static/i18n/en.yaml @@ -546,6 +546,16 @@ Errors: NotFound: Execution not found IncludeNotFound: Include not found NoTargets: No targets defined + UserSchema: + NotEnabled: Feature "User Schema" is not enabled + Type: + Missing: User Schema Type missing + AlreadyExists: User Schema Type already exists + Authenticator: + Invalid: Invalid authenticator type + NotActive: User Schema not active + NotInactive: User Schema not inactive + NotExists: User Schema does not exist AggregateTypes: action: Action @@ -559,6 +569,7 @@ AggregateTypes: feature: Feature target: Target execution: Execution + user_schema: User Schema EventTypes: execution: @@ -1236,6 +1247,12 @@ EventTypes: password: changed: Password of SMTP configuration changed removed: SMTP configuration removed + user_schema: + created: User Schema created + updated: User Schema updated + deactivated: User Schema deactivated + reactivated: User Schema reactivated + deleted: User Schema deleted Application: OIDC: diff --git a/internal/static/i18n/es.yaml b/internal/static/i18n/es.yaml index 87609eed9f..c02216ba1d 100644 --- a/internal/static/i18n/es.yaml +++ b/internal/static/i18n/es.yaml @@ -546,6 +546,16 @@ Errors: NotFound: Ejecución no encontrada IncludeNotFound: Incluir no encontrado NoTargets: No hay objetivos definidos + UserSchema: + NotEnabled: La función "Esquema de usuario" no está habilitada + Type: + Missing: Falta el tipo de esquema de usuario + AlreadyExists: El tipo de esquema de usuario ya existe + Authenticator: + Invalid: Tipo de autenticador no válido + NotActive: Esquema de usuario no activo + NotInactive: Esquema de usuario no inactivo + NotExists: El esquema de usuario no existe AggregateTypes: action: Acción @@ -559,6 +569,7 @@ AggregateTypes: feature: Característica target: Objectivo execution: Ejecución + user_schema: Esquema de usuario EventTypes: execution: @@ -1236,6 +1247,12 @@ EventTypes: password: changed: Contraseña de configuración SMTP modificada removed: Configuración SMTP eliminada + user_schema: + created: Esquema de usuario creado + updated: Esquema de usuario actualizado + deactivated: Esquema de usuario desactivado + reactivated: Esquema de usuario reactivado + deleted: Esquema de usuario eliminado Application: OIDC: diff --git a/internal/static/i18n/fr.yaml b/internal/static/i18n/fr.yaml index 66f48348f1..a0875f98b5 100644 --- a/internal/static/i18n/fr.yaml +++ b/internal/static/i18n/fr.yaml @@ -546,6 +546,16 @@ Errors: NotFound: Exécution introuvable IncludeNotFound: Inclure introuvable NoTargets: Aucune cible définie + UserSchema: + NotEnabled: La fonctionnalité "Schéma utilisateur" n'est pas activée + Type: + Missing: Type de schéma utilisateur manquant + AlreadyExists: Le type de schéma utilisateur existe déjà + Authenticator: + Invalid: Type d'authentificateur invalide + NotActive: Schéma utilisateur non actif + NotInactive: Le schéma utilisateur n'est pas inactif + NotExists: Le schéma utilisateur n'existe pas AggregateTypes: action: Action @@ -559,6 +569,7 @@ AggregateTypes: feature: Fonctionnalité target: Cible execution: Exécution + user_schema: Schéma utilisateur EventTypes: execution: @@ -1065,6 +1076,12 @@ EventTypes: deactivated: Action désactivée reactivated: Action réactivée removed: Action supprimée + user_schema: + created: Schéma utilisateur créé + updated: Schéma utilisateur mis à jour + deactivated: Schéma utilisateur désactivé + reactivated: Schéma utilisateur réactivé + deleted: Schéma utilisateur supprimé Application: OIDC: diff --git a/internal/static/i18n/it.yaml b/internal/static/i18n/it.yaml index 279bab2937..a206c5dce5 100644 --- a/internal/static/i18n/it.yaml +++ b/internal/static/i18n/it.yaml @@ -547,7 +547,16 @@ Errors: NotFound: Esecuzione non trovata IncludeNotFound: Includi non trovato NoTargets: Nessun obiettivo definito - + UserSchema: + NotEnabled: La funzionalità "Schema utente" non è abilitata + Type: + Missing: Tipo di schema utente mancante + AlreadyExists: Il tipo di schema utente esiste già + Authenticator: + Invalid: Tipo di autenticatore non valido + NotActive: Schema utente non attivo + NotInactive: Schema utente non inattivo + NotExists: Lo schema utente non esiste AggregateTypes: action: Azione @@ -561,6 +570,7 @@ AggregateTypes: feature: Funzionalità target: Bersaglio execution: Esecuzione + user_schema: Schema utente EventTypes: execution: @@ -1067,6 +1077,12 @@ EventTypes: deactivated: Azione disattivata reactivated: Azione riattivata removed: Azione rimossa + user_schema: + created: Schema utente creato + updated: Schema utente aggiornato + deactivated: Schema utente disattivato + reactivated: Schema utente riattivato + deleted: Schema utente eliminato Application: OIDC: diff --git a/internal/static/i18n/ja.yaml b/internal/static/i18n/ja.yaml index 7a0442e046..000ddf0cc0 100644 --- a/internal/static/i18n/ja.yaml +++ b/internal/static/i18n/ja.yaml @@ -535,6 +535,16 @@ Errors: NotFound: 実行が見つかりませんでした IncludeNotFound: 見つからないものを含める NoTargets: ターゲットが定義されていません + UserSchema: + NotEnabled: 機能「ユーザースキーマ」が有効になっていません + Type: + Missing: ユーザースキーマタイプがありません + AlreadyExists: ユーザースキーマタイプはすでに存在します + Authenticator: + Invalid: 無効な認証子のタイプ + NotActive: ユーザースキーマがアクティブではありません + NotInactive: ユーザースキーマが非アクティブではありません + NotExists: ユーザースキーマが存在しません AggregateTypes: action: アクション @@ -548,6 +558,7 @@ AggregateTypes: feature: 特徴 target: 目標 execution: 実行 + user_schema: ユーザースキーマ EventTypes: execution: @@ -1225,6 +1236,12 @@ EventTypes: password: changed: SMTP構成パスワードの変更 removed: SMTP構成の削除 + user_schema: + created: ーザースキーマが作成されました + updated: ユーザースキーマが更新されました + deactivated: ユーザースキーマが非アクティブ化されました + reactivated: ユーザースキーマが再アクティブ化されました + deleted: ユーザースキーマが削除されました Application: OIDC: diff --git a/internal/static/i18n/mk.yaml b/internal/static/i18n/mk.yaml index 44ea7a59f8..f8de9b3359 100644 --- a/internal/static/i18n/mk.yaml +++ b/internal/static/i18n/mk.yaml @@ -545,6 +545,16 @@ Errors: NotFound: Извршувањето не е пронајдено IncludeNotFound: Вклучете не е пронајден NoTargets: Не се дефинирани цели + UserSchema: + NotEnabled: Функцијата „Корисничка шема“ не е овозможена + Type: + Missing: Недостасува тип на корисничка шема + AlreadyExists: Тип на корисничка шема веќе постои + Authenticator: + Invalid: Неважечки тип на автентикатор + NotActive: Корисничката шема не е активна + NotInactive: Корисничката шема не е неактивна + NotExists: Корисничката шема не постои AggregateTypes: action: Акција @@ -558,6 +568,7 @@ AggregateTypes: feature: Карактеристика target: Цел execution: Извршување + user_schema: Корисничка шема EventTypes: execution: @@ -1234,6 +1245,12 @@ EventTypes: password: changed: Променета лозинка на SMTP конфигурацијата removed: Отстранета SMTP конфигурација + user_schema: + created: Создадена е корисничка шема + updated: Корисничката шема е ажурирана + deactivated: Корисничката шема е деактивирана + reactivated: Корисничката шема е реактивирана + deleted: Корисничката шема е избришана Application: OIDC: diff --git a/internal/static/i18n/nl.yaml b/internal/static/i18n/nl.yaml index 8a8fc78dd0..5c2d634ddc 100644 --- a/internal/static/i18n/nl.yaml +++ b/internal/static/i18n/nl.yaml @@ -546,6 +546,16 @@ Errors: NotFound: Uitvoering niet gevonden IncludeNotFound: Inclusief niet gevonden NoTargets: Geen doelstellingen gedefinieerd + UserSchema: + NotEnabled: Functie "Gebruikersschema" is niet ingeschakeld + Type: + Missing: Type gebruikersschema ontbreekt + AlreadyExists: Type gebruikersschema bestaat al + Authenticator: + Invalid: Ongeldig authenticatortype + NotActive: Gebruikersschema niet actief + NotInactive: Gebruikersschema niet inactief + NotExists: Gebruikersschema bestaat niet AggregateTypes: action: Actie @@ -559,6 +569,7 @@ AggregateTypes: feature: Functie target: Doel execution: Executie + user_schema: Gebruikersschema EventTypes: execution: @@ -1236,6 +1247,12 @@ EventTypes: password: changed: Wachtwoord van SMTP-configuratie gewijzigd removed: SMTP-configuratie verwijderd + user_schema: + created: Gebruikersschema gemaakt + updated: Gebruikersschema bijgewerkt + deactivated: Gebruikersschema gedeactiveerd + reactivated: Gebruikersschema opnieuw geactiveerd + deleted: Gebruikersschema verwijderd Application: OIDC: diff --git a/internal/static/i18n/pl.yaml b/internal/static/i18n/pl.yaml index fc2d7f6203..572a8b1107 100644 --- a/internal/static/i18n/pl.yaml +++ b/internal/static/i18n/pl.yaml @@ -546,6 +546,16 @@ Errors: NotFound: Nie znaleziono wykonania IncludeNotFound: Nie znaleziono uwzględnienia NoTargets: Nie zdefiniowano celów + UserSchema: + NotEnabled: Funkcja „Schemat użytkownika” nie jest włączona + Type: + Missing: Brak typu schematu użytkownika + AlreadyExists: Typ schematu użytkownika już istnieje + Authenticator: + Invalid: Nieprawidłowy typ uwierzytelnienia + NotActive: Schemat użytkownika nieaktywny + NotInactive: Schemat użytkownika nie jest nieaktywny + NotExists: Schemat użytkownika nie istnieje AggregateTypes: action: Działanie @@ -559,6 +569,7 @@ AggregateTypes: feature: Funkcja target: Cel execution: Wykonanie + user_schema: Schemat użytkownika EventTypes: execution: @@ -1236,6 +1247,12 @@ EventTypes: password: changed: Hasło konfiguracji SMTP zmienione removed: Konfiguracja SMTP usunięta + user_schema: + created: Utworzono schemat użytkownika + updated: Schemat użytkownika zaktualizowany + deactivated: Schemat użytkownika dezaktywowany + reactivated: Schemat użytkownika został ponownie aktywowany + deleted: Schemat użytkownika został usunięty Application: OIDC: diff --git a/internal/static/i18n/pt.yaml b/internal/static/i18n/pt.yaml index 3ea7cb1b34..47b71c3f8b 100644 --- a/internal/static/i18n/pt.yaml +++ b/internal/static/i18n/pt.yaml @@ -540,6 +540,16 @@ Errors: NotFound: Execução não encontrada IncludeNotFound: Incluir não encontrado NoTargets: Nenhuma meta definida + UserSchema: + NotEnabled: O recurso "Esquema do usuário" não está habilitado + Type: + Missing: Tipo de esquema de usuário ausente + AlreadyExists: O tipo de esquema de usuário já existe + Authenticator: + Invalid: Tipo de autenticador inválido + NotActive: Esquema do usuário não ativo + NotInactive: Esquema do usuário não inativo + NotExists: O esquema do usuário não existe AggregateTypes: action: Ação @@ -553,6 +563,7 @@ AggregateTypes: feature: Recurso target: Objetivo execution: Execução + user_schema: Esquema do usuário EventTypes: execution: @@ -1230,6 +1241,12 @@ EventTypes: password: changed: Senha da configuração SMTP alterada removed: Configuração SMTP removida + user_schema: + created: Esquema de usuário criado + updated: Esquema do usuário atualizado + deactivated: Esquema de usuário desativado + reactivated: Esquema do usuário reativado + deleted: Esquema do usuário excluído Application: OIDC: diff --git a/internal/static/i18n/ru.yaml b/internal/static/i18n/ru.yaml index 76ce4ecb0c..a877d37cfe 100644 --- a/internal/static/i18n/ru.yaml +++ b/internal/static/i18n/ru.yaml @@ -534,6 +534,16 @@ Errors: NotFound: Исполнение не найдено IncludeNotFound: Включить не найдено NoTargets: Цели не определены + UserSchema: + NotEnabled: Функция «Пользовательская схема» не включена + Type: + Missing: Тип пользовательской схемы отсутствует + AlreadyExists: Тип пользовательской схемы уже существует + Authenticator: + Invalid: Неверный тип аутентификатора + NotActive: Пользовательская схема не активна + NotInactive: Пользовательская схема не неактивна + NotExists: Пользовательская схема не существует AggregateTypes: action: Действие @@ -547,6 +557,7 @@ AggregateTypes: feature: Особенность target: мишень execution: Исполнение + user_schema: Пользовательская схема EventTypes: execution: @@ -1224,6 +1235,12 @@ EventTypes: password: changed: Изменен пароль конфигурации SMTP removed: Удалена конфигурация SMTP + user_schema: + created: Пользовательская схема создана + updated: Пользовательская схема обновлена + deactivated: Пользовательская схема деактивирована + reactivated: Пользовательская схема повторно активирована + deleted: Пользовательская схема удалена Application: OIDC: UnsupportedVersion: Ваша версия OIDC не поддерживается diff --git a/internal/static/i18n/zh.yaml b/internal/static/i18n/zh.yaml index 71cb5ee637..6de8ddfc83 100644 --- a/internal/static/i18n/zh.yaml +++ b/internal/static/i18n/zh.yaml @@ -546,6 +546,16 @@ Errors: NotFound: 未找到执行 IncludeNotFound: 包括未找到的内容 NoTargets: 没有定义目标 + UserSchema: + NotEnabled: 未启用“用户架构”功能 + Type: + Missing: 缺少用户架构类型 + AlreadyExists: 用户架构类型已存在 + Authenticator: + Invalid: 验证器类型无效 + NotActive: 用户架构未激活 + NotInactive: 用户架构未处于非活动状态 + NotExists: 用户架构不存在 AggregateTypes: action: 动作 @@ -559,6 +569,7 @@ AggregateTypes: feature: 特征 target: 靶 execution: 执行 + user_schema: 用户模式 EventTypes: execution: @@ -1065,6 +1076,12 @@ EventTypes: deactivated: 停用动作 reactivated: 启用动作 removed: 删除动作 + user_schema: + created: 已创建用户架构 + updated: 用户架构已更新 + deactivated: 用户架构已停用 + reactivated: 用户架构已重新激活 + deleted: 用户架构已删除 Application: OIDC: diff --git a/proto/zitadel/feature/v2beta/instance.proto b/proto/zitadel/feature/v2beta/instance.proto index 211bb6cc23..1c886efd05 100644 --- a/proto/zitadel/feature/v2beta/instance.proto +++ b/proto/zitadel/feature/v2beta/instance.proto @@ -23,13 +23,18 @@ message SetInstanceFeaturesRequest{ description: "Enable projection triggers during an introspection request. This can act as workaround if there are noticeable consistency issues in the introspection response but can have an impact on performance. We are planning to remove triggers for introspection requests in the future. Please raise an issue if you needed to enable this feature."; } ]; - optional bool oidc_legacy_introspection = 3 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "true"; description: "We have recently refactored the introspection endpoint for performance reasons. This feature can be used to rollback to the legacy implementation if unexpected bugs arise. Please raise an issue if you needed to enable this feature."; } ]; + optional bool user_schema = 4 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "true"; + description: "User Schemas allow to manage data schemas of user. If the flag is enabled, you'll be able to use the new API and its features. Note that it is still in an early stage."; + } + ]; } message SetInstanceFeaturesResponse { @@ -73,4 +78,11 @@ message GetInstanceFeaturesResponse { description: "We have recently refactored the introspection endpoint for performance reasons. This feature can be used to rollback to the legacy implementation if unexpected bugs arise. Please raise an issue if you needed to enable this feature."; } ]; + + FeatureFlag user_schema = 5 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "true"; + description: "User Schemas allow to manage data schemas of user. If the flag is enabled, you'll be able to use the new API and its features. Note that it is still in an early stage."; + } + ]; } diff --git a/proto/zitadel/feature/v2beta/system.proto b/proto/zitadel/feature/v2beta/system.proto index 07068d3623..d4bb396304 100644 --- a/proto/zitadel/feature/v2beta/system.proto +++ b/proto/zitadel/feature/v2beta/system.proto @@ -31,6 +31,13 @@ message SetSystemFeaturesRequest{ description: "We have recently refactored the introspection endpoint for performance reasons. This feature can be used to rollback to the legacy implementation if unexpected bugs arise. Please raise an issue if you needed to enable this feature."; } ]; + + optional bool user_schema = 4 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "true"; + description: "User Schemas allow to manage data schemas of user. If the flag is enabled, you'll be able to use the new API and its features. Note that it is still in an early stage."; + } + ]; } message SetSystemFeaturesResponse { @@ -67,4 +74,11 @@ message GetSystemFeaturesResponse { description: "We have recently refactored the introspection endpoint for performance reasons. This feature can be used to rollback to the legacy implementation if unexpected bugs arise. Please raise an issue if you needed to enable this feature."; } ]; + + FeatureFlag user_schema = 5 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "true"; + description: "User Schemas allow to manage data schemas of user. If the flag is enabled, you'll be able to use the new API and its features. Note that it is still in an early stage."; + } + ]; } diff --git a/proto/zitadel/user/schema/v3alpha/user_schema_service.proto b/proto/zitadel/user/schema/v3alpha/user_schema_service.proto index 16710641ee..f9d2181dbf 100644 --- a/proto/zitadel/user/schema/v3alpha/user_schema_service.proto +++ b/proto/zitadel/user/schema/v3alpha/user_schema_service.proto @@ -365,7 +365,7 @@ message CreateUserSchemaRequest { } // Defines the possible types of authenticators. repeated AuthenticatorType possible_authenticators = 3 [ - (validate.rules).repeated = {unique: true, items: {enum: {defined_only: true}}}, + (validate.rules).repeated = {unique: true, items: {enum: {defined_only: true, not_in: [0]}}}, (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "[\"AUTHENTICATOR_TYPE_USERNAME\",\"AUTHENTICATOR_TYPE_PASSWORD\",\"AUTHENTICATOR_TYPE_WEBAUTHN\"]"; } @@ -407,7 +407,7 @@ message UpdateUserSchemaRequest { // // Removal of an authenticator does not remove the authenticator on a user. repeated AuthenticatorType possible_authenticators = 4 [ - (validate.rules).repeated = {unique: true, items: {enum: {defined_only: true}}}, + (validate.rules).repeated = {unique: true, items: {enum: {defined_only: true, not_in: [0]}}}, (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "[\"AUTHENTICATOR_TYPE_USERNAME\",\"AUTHENTICATOR_TYPE_PASSWORD\",\"AUTHENTICATOR_TYPE_WEBAUTHN\"]"; }