diff --git a/.golangci.yaml b/.golangci.yaml index f480eb8c10..1cae359605 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -4,12 +4,7 @@ issues: max-issues-per-linter: 0 # Set to 0 to disable. max-same-issues: 0 - -run: - concurrency: 4 - timeout: 10m - go: '1.22' - skip-dirs: + exclude-dirs: - .artifacts - .backups - .codecov @@ -25,6 +20,11 @@ run: - openapi - proto - tools + +run: + concurrency: 4 + timeout: 10m + go: '1.22' linters: enable: # Simple linter to check that your code does not contain non-ASCII identifiers [fast: true, auto-fix: false] diff --git a/API_DESIGN.md b/API_DESIGN.md new file mode 100644 index 0000000000..3768c112aa --- /dev/null +++ b/API_DESIGN.md @@ -0,0 +1,344 @@ +# API Design + +This document describes the design principles and conventions for the ZITADEL API. It is scoped to the services and +endpoints of the proprietary ZITADEL API and does not cover any standardized APIs like OAuth 2, OpenID Connect or SCIM. + +## The Basics +ZITADEL follows an API first approach. This means all features can not only be accessed via the UI but also via the API. +The API is designed using the Protobuf specification. The Protobuf specification is then used to generate the API client +and server code in different programming languages. +The API is designed to be used by different clients, such as web applications, mobile applications, and other services. +Therefore, the API is designed to be easy to use, consistent, and reliable. + +Starting with the V2 API, the API and its services use a resource-oriented design. +This means that the API is designed around resources, which are the key entities in the system. +Each resource has a unique identifier and a set of properties that describe the resource. +The entire lifecycle of a resource can be managed using the API. + +> [!IMPORTANT] +> This style guide is a work in progress and will be updated over time. +> Not all parts of the API might follow the guidelines yet. +> However, all new endpoints and services MUST be designed according to this style guide. + +### Protobuf, gRPC and connectRPC + +The API is designed using the Protobuf specification. The Protobuf specification is used to define the API services, messages, and methods. +Starting with the V2 API, the API uses connectRPC as the main transport protocol. +[connectRPC](https://connectrpc.com/) is a protocol that is based on gRPC and HTTP/2. +It allows clients to call the API using connectRPC, gRPC and also HTTP/1.1. + +## Conventions + +The API follows the base conventions of Protobuf and connectRPC. + +Please check out their style guides and concepts for more information: +- Protobuf: https://protobuf.dev/programming-guides/style/ +- gRPC: https://grpc.io/docs/what-is-grpc/core-concepts/ +- Buf: https://buf.build/docs/best-practices/style-guide/ + +Additionally, there are some conventions that are specific to the ZITADEL API. +These conventions are described in the following sections. + +### Versioning + +The services and messages are versioned using major version numbers. This means that any change within a major version number is backward compatible. +Any breaking change requires a new major version number. +Each service is versioned independently. This means that a service can have a different version number than another service. +When creating a new service, start with version `2`, as version `1` is reserved for the old context based API and services. + +Please check out the structure Buf style guide for more information about the folder and package structure: https://buf.build/docs/best-practices/style-guide/ + +### Explicitness + +Make the handling of the API as explicit as possible. Do not make assumptions about the client's knowledge of the system or the API. +Provide clear and concise documentation for the API. + +Do not rely on implicit fallbacks or defaults if the client does not provide certain parameters. +Only use defaults if they are explicitly documented, such as returning a result set for the whole instance if no filter is provided. + +### Naming Conventions + +Names of resources, fields and methods MUST be descriptive and consistent. +Use domain-specific terminology and avoid abbreviations. +For example, use `organization_id` instead of **org_id** or **resource_owner** for the creation of a new user or when returning one. + +> [!NOTE] +> We'll update the resources in the [concepts section](https://zitadel.com/docs/concepts/structure/instance) to describe +> common resources and their meaning. +> Until then, please refer to the following issue: https://github.com/zitadel/zitadel/issues/5888 + +#### Resources and Fields + +When a context is required for creating a resource, the context is added as a field to the resource. +For example, when creating a new user, the organization's id is required. The `organization_id` is added as a field to the `CreateUserRequest`. + +```protobuf +message CreateUserRequest { + ... + string organization_id = 7 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + ]; + ... +} +``` + +Only allow providing a context where it is required. The context MUST not be provided if not required. +For example, when retrieving or updating a user, the `organization_id` is not required, since the user can be determined by the user's id. +However, it is possible to provide the `organization_id` as a filter to retrieve a list of users of a specific organization. + +Prevent the creation of global messages that are used in multiple resources unless they always follow the same pattern. +Use dedicated fields as described above or create a separate message for the specific context, that is only used in the boundary of the same resource. +For example, settings might be set as a default on the instance level, but might be overridden on the organization level. +In this case, the settings could share the same `SettingsContext` message to determine the context of the settings. +But do not create a global `Context` message that is used across the whole API if there are different scenarios and different fields required for the context. +The same applies to messages that are returned by multiple resources. +For example, information about the `User` might be different when managing the user resource itself than when it's returned +as part of an authorization or a manager role, where only limited information is needed. + +Prevent reusing messages for the creation and the retrieval of a resource. +Returning messages might contain additional information that is not required or even not available for the creation of the resource. +What might sound obvious when designing the CreateUserRequest for example, where only an `organization_id` but not the +`organization_name` is available, might not be so obvious when designing some sub-resource like a user's `IdentityProviderLink`, +which might contain an `identity_provider_name` when returned but not when created. + +```protobuf +message CreateUserRequest { + ... + repreated AddIdentityProviderLink identity_provider_links = 8; + ... +} + +message AddIdentityProviderLink { + string identity_provider_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + ]; + string user_id = 2 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + ]; + string user_name = 3; +} + +message IdentiyProviderLink { + string identity_provider_id = 1; + string identity_provider_name = 2; + string user_id = 3; + string user_name = 4; +} +``` + +#### Operations and Methods + +Methods on a resource MUST be named using the following convention: + +| Operation | Method Name | Description | +|-----------|--------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Create | Create\ | Create a new resource. If the new resource conflicts with an existing resources uniqueness (id, loginname, ...) the creation MUST be prevented and an error returned. | +| Update | Update\ | Update an existing resource. In most cases this SHOULD allow partial updates. If there are exception, they MUST be explicitly documented on the endpoint. The resource MUST already exists. An error is returned otherwise. | +| Delete | Delete\ | Delete an existing resource. If the resource does not exist, no error SHOULD be returned. In case of an exception to this rule, the behavior MUST clearly be documented. | +| Set | Set\ | Set a resource. This will replace the existing resource with the new resource. In case where the creation and update of a resource do not need to be differentiated, a single `Set` method SHOULD be used. It SHOULD allow partial changes. | +| Get | Get\ | Retrieve a single resource by its unique identifier. If the resource does not exist, an error MUST be returned. | +| List | List\ | Retrieve a list of resources. The endpoint SHOULD provide options to filter, sort and paginate. | + +Methods on a list of resources MUST be named using the following convention: + +| Operation | Method Name | Description | +|-----------|--------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Add | Add\ | Add a new resource to a list. Any existing unique constraint (id, loginname, ...) will prevent the addition and return an error. | +| Remove | Remove\ | Remove an existing resource from a list. If the resource does not exist in the list, no error SHOULD be returned. In case of an exception to this rule, the behavior MUST clearly be documented. | +| Set | Set\ | Set a list of resources. This will replace the existing list with the new list. | + +Additionally, state changes, specific actions or operations that do not fit into the CRUD operations SHOULD be named according to the action that is performed: +- `Activate` or `Deactivate` for enabling or disabling a resource. +- `Verify` for verifying a resource. +- `Send` for sending a resource. +- etc. + +## Authentication and Authorization + +The API uses OAuth 2 for authorization. There are corresponding middlewares that check the access token for validity and +automatically return an error if the token is invalid. + +Permissions grated to the user are organization specific and might only be checked based on the queried resource. +Therefore, the API does not check the permissions itself but relies on the checks of the functions that are called by the API. +Required permissions need to be documented in the [API documentation](#documentation). + +## Pagination + +The API uses pagination for listing resources. The client can specify a limit and an offset to retrieve a subset of the resources. +Additionally, the client can specify sorting options to sort the resources by a specific field. + +Most listing methods SHOULD provide use the `ListQuery` message to allow the client to specify the limit, offset, and sorting options. +```protobuf + +// ListQuery is a general query object for lists to allow pagination and sorting. +message ListQuery { + uint64 offset = 1; + // limit is the maximum amount of objects returned. The default is set to 100 + // with a maximum of 1000 in the runtime configuration. + // If the limit exceeds the maximum configured ZITADEL will throw an error. + // If no limit is present the default is taken. + uint32 limit = 2; + // Asc is the sorting order. If true the list is sorted ascending, if false + // the list is sorted descending. The default is descending. + bool asc = 3; +} +``` +On the corresponding responses the `ListDetails` can be used to return the total count of the resources +and allow the user to handle their offset and limit accordingly. + + +## Error Handling + +The API returns machine-readable errors in the response body. This includes a status code, an error code and possibly +some details about the error. See the following sections for more information about the status codes, error codes and error messages. + +### Status Codes + +The API uses status codes to indicate the status of a request. Depending on the protocol used to call the API, +the status code is returned as an HTTP status code or as a gRPC / connectRPC status code. +Check the possible status codes https://zitadel.com/docs/apis/statuscodes + +### Error Codes + +Additionally to the status code, the API returns unique error codes for each type of error. +The error codes are used to identify a specific error and can be used to handle the error programmatically. + +> [!NOTE] +> Currently, ZITADEL might already return some error codes. However, they do not follow a specific pattern yet +> and are not documented. We will update the error codes and document them in the future. + +### Error Message and Details + +The API returns additional details about the error in the response body. +This includes a human-readable error message and additional information that can help the client to understand the error +as well as machine-readable details that can be used to handle the error programmatically. +Error details use the Google RPC error details format: https://github.com/googleapis/googleapis/blob/master/google/rpc/error_details.proto + +### Example + +HTTP/1.1 example: +``` +HTTP/1.1 400 Bad Request +Content-Type: application/json + +{ + "code": "user_missing_information", + "message": "missing required information for the creation of the user", + "details": [ + { + "@type": "type.googleapis.com/google.rpc.BadRequest", + "fieldViolations": [ + { + "field": "given_name", + "description": "given name is required" + }, + { + "field": "family_name", + "description": "family name is required" + } + ] + } + ] +} +``` + +gRPC / connectRPC example: +``` +HTTP/2.0 200 OK +Content-Type: application/grpc +Grpc-Message: missing required information for the creation of the user +Grpc-Status: 3 + +{ + "code": "user_missing_information", + "message": "missing required information for the creation of the user", + "details": [ + { + "@type": "type.googleapis.com/google.rpc.BadRequest", + "fieldViolations": [ + { + "field": "given_name", + "description": "given name is required" + }, + { + "field": "family_name", + "description": "family name is required" + } + ] + } + ] +} +``` + +### Documentation + +- Document the purpose of the API, the services, the endpoints, the request and response messages, the error codes and the status codes. +- Describe the fields of the request and response messages, the purpose and if needed the constraints. +- Document if the endpoints requires specific permissions or roles. +- Document and explain the possible error codes and the error messages that can be returned by the API. + +#### Examples + +```protobuf +// CreateUser will create a new user (human or machine) in the specified organization. +// The username must be unique. +// +// For human users: +// The user will receive a verification email if the email address is not marked as verified. +// You can pass a hashed_password. This allows migrating your users from your own system to ZITADEL, without any password +// reset for the user. Please check the required format and supported algorithms: . +// +// Required permission: +// - user.write +// +// Error Codes: +// - user_missing_information: The request is missing required information (either given_name, family_name and/or email) for the creation of the user. Check error details for the missing fields. +// - user_already_exists: The user already exists. The username must be unique. +// - invalid_request: Your request does not have a valid format. Check error details for the reason. +// - permission_denied: You do not have the required permissions to access the requested resource. +// - unauthenticated: You are not authenticated. Please provide a valid access token. +rpc CreatUser(CreatUserRequest) returns (CreatUserResponse) {} +``` + +```protobuf +// ListUsers will return all matching users. By default, we will return all users of your instance that you have permission to read. Make sure to include a limit and sorting for pagination. +// +// Required permission: +// - user.read +// - no permission required to own user +// +// Error Codes: +// - invalid_request: Your request does not have a valid format. Check error details for the reason. +// - permission_denied: You do not have the required permissions to access the requested resource. +// - unauthenticated: You are not authenticated. Please provide a valid access token. +rpc ListUsers(ListUsersRequest) returns (ListUsersResponse) {} +``` + +```protobuf +// VerifyEmail will verify the provided verification code and mark the email as verified on success. +// An error is returned if the verification code is invalid or expired or if the user does not exist. +// Note that if multiple verification codes are generated, only the last one is valid. +// +// Required permission: +// - no permission required, the user must be authenticated +// +// Error Codes: +// - invalid_verification_code: The verification code is invalid or expired. +// - invalid_request: Your request does not have a valid format. Check error details for the reason. +// - unauthenticated: You are not authenticated. Please provide a valid access token. +rpc VerifyEmail (VerifyEmailRequest) returns (VerifyEmailResponse) {} +``` + +```protobuf +message VerifyEmailRequest{ + // The id of the user to verify the email for. + string user_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200} + ]; + // The verification code generated and sent to the user. + string verification_code = 2 [ + (validate.rules).string = {min_len: 1, max_len: 20} + ]; +} + +``` \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e56ca307d1..6fafd3dd6f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -141,6 +141,13 @@ Replace "policeman" with "police officer," "manpower" with "workforce," and "bus Ableist language includes words or phrases such as crazy, insane, blind to or blind eye to, cripple, dumb, and others. Choose alternative words depending on the context. +### API + +ZITADEL follows an API first approach. This means all features can not only be accessed via the UI but also via the API. +The API is designed to be used by different clients, such as web applications, mobile applications, and other services. +Therefore, the API is designed to be easy to use, consistent, and reliable. +Please check out the dedicated [API guidelines](./API_DESIGN.md) page when contributing to the API. + ### Developing ZITADEL with Dev Containers Follow the instructions provided by your code editor/IDE to initiate the development container. This typically involves opening the "Command Palette" or similar functionality and searching for commands related to "Dev Containers" or "Remote Containers". The quick start guide for VS Code can found [here](https://code.visualstudio.com/docs/devcontainers/containers#_quick-start-open-an-existing-folder-in-a-container) diff --git a/README.md b/README.md index 592952cdc2..5d4aecf441 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ We provide you with a wide range of out-of-the-box features to accelerate your p :white_check_mark: LDAP :white_check_mark: Passkeys / FIDO2 :white_check_mark: OTP +:white_check_mark: SCIM 2.0 Server and an unlimited audit trail is there for you, ready to use. With ZITADEL, you are assured of a robust and customizable turnkey solution for all your authentication and authorization needs. @@ -124,6 +125,7 @@ Authentication - [Custom sessions](https://zitadel.com/docs/guides/integrate/login-ui/username-password) if you need to go beyond OIDC or SAML - [Machine-to-machine](https://zitadel.com/docs/guides/integrate/service-users/authenticate-service-users) with JWT profile, Personal Access Tokens (PAT), and Client Credentials - [Token exchange and impersonation](https://zitadel.com/docs/guides/integrate/token-exchange) +- [Beta: Hosted Login V2](https://zitadel.com/docs/guides/integrate/login/hosted-login#hosted-login-version-2-beta) our new login version 2.0 Multi-Tenancy @@ -137,10 +139,11 @@ Integration - [GRPC and REST APIs](https://zitadel.com/docs/apis/introduction) for every functionality and resource - [Actions](https://zitadel.com/docs/apis/actions/introduction) to call any API, send webhooks, adjust workflows, or customize tokens - [Role Based Access Control (RBAC)](https://zitadel.com/docs/guides/integrate/retrieve-user-roles) +- [SCIM 2.0 Server](https://zitadel.com/docs/apis/scim2) - [Examples and SDKs](https://zitadel.com/docs/sdk-examples/introduction) - [Audit Log and SOC/SIEM](https://zitadel.com/docs/guides/integrate/external-audit-log) - [User registration and onboarding](https://zitadel.com/docs/guides/integrate/onboarding) -- [Hosted and custom login user interface](https://zitadel.com/docs/guides/integrate/login-ui) +- [Hosted and custom login user interface](https://zitadel.com/docs/guides/integrate/login/login-users) Self-Service - [Self-registration](https://zitadel.com/docs/concepts/features/selfservice#registration) including verification @@ -187,6 +190,11 @@ Use [Console](https://zitadel.com/docs/guides/manage/console/overview) or our [A [![Console Showcase](https://user-images.githubusercontent.com/1366906/223663344-67038d5f-4415-4285-ab20-9a4d397e2138.gif)](http://www.youtube.com/watch?v=RPpHktAcCtk "Console Showcase") +### Login V2 + +Check out our new Login V2 version in our [typescript repository](https://github.com/zitadel/typescript) or in our [documentation](https://zitadel.com/docs/guides/integrate/login/hosted-login#hosted-login-version-2-beta) +[![New Login Showcase](https://github.com/user-attachments/assets/cb5c5212-128b-4dc9-b11d-cabfd3f73e26)] + ## Security You can find our security policy [here](./SECURITY.md). diff --git a/cmd/defaults.yaml b/cmd/defaults.yaml index 3615c7fa34..71ad22a4f9 100644 --- a/cmd/defaults.yaml +++ b/cmd/defaults.yaml @@ -416,12 +416,10 @@ Projections: TransactionDuration: 0s BulkLimit: 2000 - # The Notifications projection is used for sending emails and SMS to users + # The Notifications projection is used for preparing the messages (emails and SMS) to be sent to users Notifications: # As notification projections don't result in database statements, retries don't have an effect MaxFailureCount: 10 # ZITADEL_PROJECTIONS_CUSTOMIZATIONS_NOTIFICATIONS_MAXFAILURECOUNT - # Sending emails can take longer than 500ms - TransactionDuration: 5s # ZITADEL_PROJECTIONS_CUSTOMIZATIONS_NOTIFICATIONS_TRANSACTIONDURATION password_complexities: TransactionDuration: 2s # ZITADEL_PROJECTIONS_CUSTOMIZATIONS_PASSWORD_COMPLEXITIES_TRANSACTIONDURATION lockout_policy: @@ -453,34 +451,12 @@ Notifications: # If set to 0, no notification request events will be handled. This can be useful when running in # multi binary / pod setup and allowing only certain executables to process the events. Workers: 1 # ZITADEL_NOTIFIACATIONS_WORKERS - # The amount of events a single worker will process in a run. - BulkLimit: 10 # ZITADEL_NOTIFIACATIONS_BULKLIMIT - # Time interval between scheduled notifications for request events - RequeueEvery: 5s # ZITADEL_NOTIFIACATIONS_REQUEUEEVERY - # The amount of workers processing the notification retry events. - # If set to 0, no notification retry events will be handled. This can be useful when running in - # multi binary / pod setup and allowing only certain executables to process the events. - RetryWorkers: 1 # ZITADEL_NOTIFIACATIONS_RETRYWORKERS - # Time interval between scheduled notifications for retry events - RetryRequeueEvery: 5s # ZITADEL_NOTIFIACATIONS_RETRYREQUEUEEVERY - # Only instances are projected, for which at least a projection-relevant event exists within the timeframe - # from HandleActiveInstances duration in the past until the projection's current time - # If set to 0 (default), every instance is always considered active - HandleActiveInstances: 0s # ZITADEL_NOTIFIACATIONS_HANDLEACTIVEINSTANCES - # The maximum duration a transaction remains open - # before it spots left folding additional events - # and updates the table. + # The maximum duration a job can do it's work before it is considered as failed. TransactionDuration: 10s # ZITADEL_NOTIFIACATIONS_TRANSACTIONDURATION # Automatically cancel the notification after the amount of failed attempts MaxAttempts: 3 # ZITADEL_NOTIFIACATIONS_MAXATTEMPTS # Automatically cancel the notification if it cannot be handled within a specific time MaxTtl: 5m # ZITADEL_NOTIFIACATIONS_MAXTTL - # Failed attempts are retried after a confogired delay (with exponential backoff). - # Set a minimum and maximum delay and a factor for the backoff - MinRetryDelay: 5s # ZITADEL_NOTIFIACATIONS_MINRETRYDELAY - MaxRetryDelay: 1m # ZITADEL_NOTIFIACATIONS_MAXRETRYDELAY - # Any factor below 1 will be set to 1 - RetryDelayFactor: 1.5 # ZITADEL_NOTIFIACATIONS_RETRYDELAYFACTOR Auth: # See Projections.BulkLimit @@ -1321,6 +1297,8 @@ InternalAuthZ: - "userschema.read" - "userschema.write" - "userschema.delete" + - "session.read" + - "session.delete" - Role: "IAM_OWNER_VIEWER" Permissions: - "iam.read" @@ -1356,6 +1334,7 @@ InternalAuthZ: - "action.target.read" - "action.execution.read" - "userschema.read" + - "session.read" - Role: "IAM_ORG_MANAGER" Permissions: - "org.read" diff --git a/cmd/mirror/projections.go b/cmd/mirror/projections.go index a4987a48f6..c15747e74a 100644 --- a/cmd/mirror/projections.go +++ b/cmd/mirror/projections.go @@ -221,6 +221,7 @@ func projections( keys.OIDC, config.OIDC.DefaultBackChannelLogoutLifetime, client, + nil, ) config.Auth.Spooler.Client = client diff --git a/cmd/setup/49.go b/cmd/setup/49.go new file mode 100644 index 0000000000..28bf797110 --- /dev/null +++ b/cmd/setup/49.go @@ -0,0 +1,39 @@ +package setup + +import ( + "context" + "embed" + "fmt" + + "github.com/zitadel/logging" + + "github.com/zitadel/zitadel/internal/database" + "github.com/zitadel/zitadel/internal/eventstore" +) + +type InitPermittedOrgsFunction struct { + eventstoreClient *database.DB +} + +var ( + //go:embed 49/*.sql + permittedOrgsFunction embed.FS +) + +func (mig *InitPermittedOrgsFunction) Execute(ctx context.Context, _ eventstore.Event) error { + statements, err := readStatements(permittedOrgsFunction, "49", "") + if err != nil { + return err + } + for _, stmt := range statements { + logging.WithFields("file", stmt.file, "migration", mig.String()).Info("execute statement") + if _, err := mig.eventstoreClient.ExecContext(ctx, stmt.query); err != nil { + return fmt.Errorf("%s %s: %w", mig.String(), stmt.file, err) + } + } + return nil +} + +func (*InitPermittedOrgsFunction) String() string { + return "49_init_permitted_orgs_function" +} diff --git a/cmd/setup/49/01-permitted_orgs_function.sql b/cmd/setup/49/01-permitted_orgs_function.sql new file mode 100644 index 0000000000..9f291c016b --- /dev/null +++ b/cmd/setup/49/01-permitted_orgs_function.sql @@ -0,0 +1,56 @@ +DROP FUNCTION IF EXISTS eventstore.permitted_orgs; + +CREATE OR REPLACE FUNCTION eventstore.permitted_orgs( + instanceId TEXT + , userId TEXT + , perm TEXT + , filter_orgs TEXT + + , org_ids OUT TEXT[] +) + LANGUAGE 'plpgsql' + STABLE +AS $$ +DECLARE + matched_roles TEXT[]; -- roles containing permission +BEGIN + SELECT array_agg(rp.role) INTO matched_roles + FROM eventstore.role_permissions rp + WHERE rp.instance_id = instanceId + AND rp.permission = perm; + + -- First try if the permission was granted thru an instance-level role + DECLARE + has_instance_permission bool; + BEGIN + SELECT true INTO has_instance_permission + FROM eventstore.instance_members im + WHERE im.role = ANY(matched_roles) + AND im.instance_id = instanceId + AND im.user_id = userId + LIMIT 1; + + IF has_instance_permission THEN + -- Return all organizations or only those in filter_orgs + SELECT array_agg(o.org_id) INTO org_ids + FROM eventstore.instance_orgs o + WHERE o.instance_id = instanceId + AND CASE WHEN filter_orgs != '' + THEN o.org_id IN (filter_orgs) + ELSE TRUE END; + RETURN; + END IF; + END; + + -- Return the organizations where permission were granted thru org-level roles + SELECT array_agg(org_id) INTO org_ids + FROM ( + SELECT DISTINCT om.org_id + FROM eventstore.org_members om + WHERE om.role = ANY(matched_roles) + AND om.instance_id = instanceID + AND om.user_id = userId + ); + RETURN; +END; +$$; diff --git a/cmd/setup/50.go b/cmd/setup/50.go new file mode 100644 index 0000000000..fea69f79ce --- /dev/null +++ b/cmd/setup/50.go @@ -0,0 +1,27 @@ +package setup + +import ( + "context" + _ "embed" + + "github.com/zitadel/zitadel/internal/database" + "github.com/zitadel/zitadel/internal/eventstore" +) + +var ( + //go:embed 50.sql + addUsePKCE string +) + +type IDPTemplate6UsePKCE struct { + dbClient *database.DB +} + +func (mig *IDPTemplate6UsePKCE) Execute(ctx context.Context, _ eventstore.Event) error { + _, err := mig.dbClient.ExecContext(ctx, addUsePKCE) + return err +} + +func (mig *IDPTemplate6UsePKCE) String() string { + return "50_idp_templates6_add_use_pkce" +} diff --git a/cmd/setup/50.sql b/cmd/setup/50.sql new file mode 100644 index 0000000000..4ff0fd7042 --- /dev/null +++ b/cmd/setup/50.sql @@ -0,0 +1,2 @@ +ALTER TABLE IF EXISTS projections.idp_templates6_oauth2 ADD COLUMN IF NOT EXISTS use_pkce BOOLEAN; +ALTER TABLE IF EXISTS projections.idp_templates6_oidc ADD COLUMN IF NOT EXISTS use_pkce BOOLEAN; \ No newline at end of file diff --git a/cmd/setup/config.go b/cmd/setup/config.go index d782a32dd6..6706d219e6 100644 --- a/cmd/setup/config.go +++ b/cmd/setup/config.go @@ -137,6 +137,8 @@ type Steps struct { s46InitPermissionFunctions *InitPermissionFunctions s47FillMembershipFields *FillMembershipFields s48Apps7SAMLConfigsLoginVersion *Apps7SAMLConfigsLoginVersion + s49InitPermittedOrgsFunction *InitPermittedOrgsFunction + s50IDPTemplate6UsePKCE *IDPTemplate6UsePKCE } func MustNewSteps(v *viper.Viper) *Steps { diff --git a/cmd/setup/river_queue_repeatable.go b/cmd/setup/river_queue_repeatable.go index e88293256b..5248894a8f 100644 --- a/cmd/setup/river_queue_repeatable.go +++ b/cmd/setup/river_queue_repeatable.go @@ -16,7 +16,7 @@ func (mig *RiverMigrateRepeatable) Execute(ctx context.Context, _ eventstore.Eve if mig.client.Type() != "postgres" { return nil } - return queue.New(mig.client).ExecuteMigrations(ctx) + return queue.NewMigrator(mig.client).Execute(ctx) } func (mig *RiverMigrateRepeatable) String() string { diff --git a/cmd/setup/setup.go b/cmd/setup/setup.go index bfa289ab36..b693df3022 100644 --- a/cmd/setup/setup.go +++ b/cmd/setup/setup.go @@ -37,6 +37,7 @@ import ( notify_handler "github.com/zitadel/zitadel/internal/notification" "github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/internal/query/projection" + "github.com/zitadel/zitadel/internal/queue" es_v4 "github.com/zitadel/zitadel/internal/v2/eventstore" es_v4_pg "github.com/zitadel/zitadel/internal/v2/eventstore/postgres" "github.com/zitadel/zitadel/internal/webauthn" @@ -174,37 +175,12 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string) steps.s46InitPermissionFunctions = &InitPermissionFunctions{eventstoreClient: dbClient} steps.s47FillMembershipFields = &FillMembershipFields{eventstore: eventstoreClient} steps.s48Apps7SAMLConfigsLoginVersion = &Apps7SAMLConfigsLoginVersion{dbClient: dbClient} + steps.s49InitPermittedOrgsFunction = &InitPermittedOrgsFunction{eventstoreClient: dbClient} + steps.s50IDPTemplate6UsePKCE = &IDPTemplate6UsePKCE{dbClient: dbClient} err = projection.Create(ctx, dbClient, eventstoreClient, config.Projections, nil, nil, nil) logging.OnError(err).Fatal("unable to start projections") - repeatableSteps := []migration.RepeatableMigration{ - &externalConfigChange{ - es: eventstoreClient, - ExternalDomain: config.ExternalDomain, - ExternalPort: config.ExternalPort, - ExternalSecure: config.ExternalSecure, - defaults: config.SystemDefaults, - }, - &projectionTables{ - es: eventstoreClient, - Version: build.Version(), - }, - &DeleteStaleOrgFields{ - eventstore: eventstoreClient, - }, - &FillFieldsForInstanceDomains{ - eventstore: eventstoreClient, - }, - &SyncRolePermissions{ - eventstore: eventstoreClient, - rolePermissionMappings: config.InternalAuthZ.RolePermissionMappings, - }, - &RiverMigrateRepeatable{ - client: dbClient, - }, - } - for _, step := range []migration.Migration{ steps.s14NewEventsTable, steps.s40InitPushFunc, @@ -212,6 +188,7 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string) steps.s2AssetsTable, steps.s28AddFieldTable, steps.s31AddAggregateIndexToFields, + steps.s46InitPermissionFunctions, steps.FirstInstance, steps.s5LastFailed, steps.s6OwnerRemoveColumns, @@ -236,12 +213,43 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string) steps.s38BackChannelLogoutNotificationStart, steps.s44ReplaceCurrentSequencesIndex, steps.s45CorrectProjectOwners, - steps.s46InitPermissionFunctions, steps.s47FillMembershipFields, + steps.s49InitPermittedOrgsFunction, + steps.s50IDPTemplate6UsePKCE, } { mustExecuteMigration(ctx, eventstoreClient, step, "migration failed") } + commands, _, _, _ := startCommandsQueries(ctx, eventstoreClient, eventstoreV4, dbClient, masterKey, config) + + repeatableSteps := []migration.RepeatableMigration{ + &externalConfigChange{ + es: eventstoreClient, + ExternalDomain: config.ExternalDomain, + ExternalPort: config.ExternalPort, + ExternalSecure: config.ExternalSecure, + defaults: config.SystemDefaults, + }, + &projectionTables{ + es: eventstoreClient, + Version: build.Version(), + }, + &DeleteStaleOrgFields{ + eventstore: eventstoreClient, + }, + &FillFieldsForInstanceDomains{ + eventstore: eventstoreClient, + }, + &SyncRolePermissions{ + commands: commands, + eventstore: eventstoreClient, + rolePermissionMappings: config.InternalAuthZ.RolePermissionMappings, + }, + &RiverMigrateRepeatable{ + client: dbClient, + }, + } + for _, repeatableStep := range repeatableSteps { mustExecuteMigration(ctx, eventstoreClient, repeatableStep, "unable to migrate repeatable step") } @@ -267,11 +275,6 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string) initProjections( ctx, eventstoreClient, - eventstoreV4, - dbClient, - dbClient, - masterKey, - config, ) } } @@ -332,18 +335,20 @@ func readStatements(fs embed.FS, folder, typ string) ([]statement, error) { return statements, nil } -func initProjections( +func startCommandsQueries( ctx context.Context, eventstoreClient *eventstore.Eventstore, eventstoreV4 *es_v4.EventStore, - queryDBClient, - projectionDBClient *database.DB, + dbClient *database.DB, masterKey string, config *Config, +) ( + *command.Commands, + *query.Queries, + *admin_view.View, + *auth_view.View, ) { - logging.Info("init-projections is currently in beta") - - keyStorage, err := cryptoDB.NewKeyStorage(queryDBClient, masterKey) + keyStorage, err := cryptoDB.NewKeyStorage(dbClient, masterKey) logging.OnError(err).Fatal("unable to start key storage") keys, err := encryption.EnsureEncryptionKeys(ctx, config.EncryptionKeys, keyStorage) @@ -351,7 +356,7 @@ func initProjections( err = projection.Create( ctx, - queryDBClient, + dbClient, eventstoreClient, projection.Config{ RetryFailedAfter: config.InitProjections.RetryFailedAfter, @@ -363,19 +368,15 @@ func initProjections( config.SystemAPIUsers, ) logging.OnError(err).Fatal("unable to start projections") - for _, p := range projection.Projections() { - err := migration.Migrate(ctx, eventstoreClient, p) - logging.WithFields("name", p.String()).OnError(err).Fatal("migration failed") - } - staticStorage, err := config.AssetStorage.NewStorage(queryDBClient.DB) + staticStorage, err := config.AssetStorage.NewStorage(dbClient.DB) logging.OnError(err).Fatal("unable to start asset storage") - adminView, err := admin_view.StartView(queryDBClient) + adminView, err := admin_view.StartView(dbClient) logging.OnError(err).Fatal("unable to start admin view") admin_handler.Register(ctx, admin_handler.Config{ - Client: queryDBClient, + Client: dbClient, Eventstore: eventstoreClient, BulkLimit: config.InitProjections.BulkLimit, FailureCountUntilSkip: uint64(config.InitProjections.MaxFailureCount), @@ -383,22 +384,18 @@ func initProjections( adminView, staticStorage, ) - for _, p := range admin_handler.Projections() { - err := migration.Migrate(ctx, eventstoreClient, p) - logging.WithFields("name", p.String()).OnError(err).Fatal("migration failed") - } sessionTokenVerifier := internal_authz.SessionTokenVerifier(keys.OIDC) - cacheConnectors, err := connector.StartConnectors(config.Caches, queryDBClient) + cacheConnectors, err := connector.StartConnectors(config.Caches, dbClient) logging.OnError(err).Fatal("unable to start caches") queries, err := query.StartQueries( ctx, eventstoreClient, eventstoreV4.Querier, - queryDBClient, - projectionDBClient, + dbClient, + dbClient, cacheConnectors, config.Projections, config.SystemDefaults, @@ -420,11 +417,11 @@ func initProjections( ) logging.OnError(err).Fatal("unable to start queries") - authView, err := auth_view.StartView(queryDBClient, keys.OIDC, queries, eventstoreClient) + authView, err := auth_view.StartView(dbClient, keys.OIDC, queries, eventstoreClient) logging.OnError(err).Fatal("unable to start admin view") auth_handler.Register(ctx, auth_handler.Config{ - Client: queryDBClient, + Client: dbClient, Eventstore: eventstoreClient, BulkLimit: config.InitProjections.BulkLimit, FailureCountUntilSkip: uint64(config.InitProjections.MaxFailureCount), @@ -432,16 +429,13 @@ func initProjections( authView, queries, ) - for _, p := range auth_handler.Projections() { - err := migration.Migrate(ctx, eventstoreClient, p) - logging.WithFields("name", p.String()).OnError(err).Fatal("migration failed") - } - authZRepo, err := authz.Start(queries, eventstoreClient, queryDBClient, keys.OIDC, config.ExternalSecure) + authZRepo, err := authz.Start(queries, eventstoreClient, dbClient, keys.OIDC, config.ExternalSecure) logging.OnError(err).Fatal("unable to start authz repo") permissionCheck := func(ctx context.Context, permission, orgID, resourceID string) (err error) { return internal_authz.CheckPermission(ctx, authZRepo, config.InternalAuthZ.RolePermissionMappings, permission, orgID, resourceID) } + commands, err := command.StartCommands(ctx, eventstoreClient, cacheConnectors, @@ -473,6 +467,11 @@ func initProjections( config.DefaultInstance.SecretGenerators, ) logging.OnError(err).Fatal("unable to start commands") + q, err := queue.NewQueue(&queue.Config{ + Client: dbClient, + }) + logging.OnError(err).Fatal("unable to start queue") + notify_handler.Register( ctx, config.Projections.Customizations["notifications"], @@ -494,8 +493,34 @@ func initProjections( keys.SMS, keys.OIDC, config.OIDC.DefaultBackChannelLogoutLifetime, - queryDBClient, + dbClient, + q, ) + + return commands, queries, adminView, authView +} + +func initProjections( + ctx context.Context, + eventstoreClient *eventstore.Eventstore, +) { + logging.Info("init-projections is currently in beta") + + for _, p := range projection.Projections() { + err := migration.Migrate(ctx, eventstoreClient, p) + logging.WithFields("name", p.String()).OnError(err).Fatal("migration failed") + } + + for _, p := range admin_handler.Projections() { + err := migration.Migrate(ctx, eventstoreClient, p) + logging.WithFields("name", p.String()).OnError(err).Fatal("migration failed") + } + + for _, p := range auth_handler.Projections() { + err := migration.Migrate(ctx, eventstoreClient, p) + logging.WithFields("name", p.String()).OnError(err).Fatal("migration failed") + } + for _, p := range notify_handler.Projections() { err := migration.Migrate(ctx, eventstoreClient, p) logging.WithFields("name", p.String()).OnError(err).Fatal("migration failed") diff --git a/cmd/setup/sync_role_permissions.go b/cmd/setup/sync_role_permissions.go index b38b075d82..5c380265b5 100644 --- a/cmd/setup/sync_role_permissions.go +++ b/cmd/setup/sync_role_permissions.go @@ -2,29 +2,22 @@ package setup import ( "context" - "database/sql" _ "embed" "fmt" - "strings" "github.com/zitadel/logging" "github.com/zitadel/zitadel/internal/api/authz" - "github.com/zitadel/zitadel/internal/database" + "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/repository/instance" - "github.com/zitadel/zitadel/internal/repository/permission" -) - -var ( - //go:embed sync_role_permissions.sql - getRolePermissionOperationsQuery string ) // SyncRolePermissions is a repeatable step which synchronizes the InternalAuthZ // RolePermissionMappings from the configuration to the database. // This is needed until role permissions are manageable over the API. type SyncRolePermissions struct { + commands *command.Commands eventstore *eventstore.Eventstore rolePermissionMappings []authz.RoleMapping } @@ -38,18 +31,11 @@ func (mig *SyncRolePermissions) Execute(ctx context.Context, _ eventstore.Event) func (mig *SyncRolePermissions) executeSystem(ctx context.Context) error { logging.WithFields("migration", mig.String()).Info("prepare system role permission sync events") - - target := rolePermissionMappingsToDatabaseMap(mig.rolePermissionMappings, true) - cmds, err := mig.synchronizeCommands(ctx, "SYSTEM", target) + details, err := mig.commands.SynchronizeRolePermission(ctx, "SYSTEM", mig.rolePermissionMappings) if err != nil { return err } - events, err := mig.eventstore.Push(ctx, cmds...) - if err != nil { - return err - } - - logging.WithFields("migration", mig.String(), "pushed_events", len(events)).Info("pushed system role permission sync events") + logging.WithFields("migration", mig.String(), "sequence", details.Sequence).Info("pushed system role permission sync events") return nil } @@ -70,51 +56,17 @@ func (mig *SyncRolePermissions) executeInstances(ctx context.Context) error { if err != nil { return err } - target := rolePermissionMappingsToDatabaseMap(mig.rolePermissionMappings, false) for i, instanceID := range instances { logging.WithFields("instance_id", instanceID, "migration", mig.String(), "progress", fmt.Sprintf("%d/%d", i+1, len(instances))).Info("prepare instance role permission sync events") - cmds, err := mig.synchronizeCommands(ctx, instanceID, target) + details, err := mig.commands.SynchronizeRolePermission(ctx, instanceID, mig.rolePermissionMappings) if err != nil { return err } - events, err := mig.eventstore.Push(ctx, cmds...) - if err != nil { - return err - } - logging.WithFields("instance_id", instanceID, "migration", mig.String(), "pushed_events", len(events)).Info("pushed instance role permission sync events") + logging.WithFields("instance_id", instanceID, "migration", mig.String(), "sequence", details.Sequence).Info("pushed instance role permission sync events") } return nil } -// synchronizeCommands checks the current state of role permissions in the eventstore for the aggregate. -// It returns the commands required to reach the desired state passed in target. -// For system level permissions aggregateID must be set to `SYSTEM`, -// else it is the instance ID. -func (mig *SyncRolePermissions) synchronizeCommands(ctx context.Context, aggregateID string, target database.Map[[]string]) (cmds []eventstore.Command, err error) { - aggregate := permission.NewAggregate(aggregateID) - err = mig.eventstore.Client().QueryContext(ctx, func(rows *sql.Rows) error { - for rows.Next() { - var operation, role, perm string - if err := rows.Scan(&operation, &role, &perm); err != nil { - return err - } - logging.WithFields("aggregate_id", aggregateID, "migration", mig.String(), "operation", operation, "role", role, "permission", perm).Debug("sync role permission") - switch operation { - case "add": - cmds = append(cmds, permission.NewAddedEvent(ctx, aggregate, role, perm)) - case "remove": - cmds = append(cmds, permission.NewRemovedEvent(ctx, aggregate, role, perm)) - } - } - return rows.Close() - - }, getRolePermissionOperationsQuery, aggregateID, target) - if err != nil { - return nil, err - } - return cmds, err -} - func (*SyncRolePermissions) String() string { return "repeatable_sync_role_permissions" } @@ -122,13 +74,3 @@ func (*SyncRolePermissions) String() string { func (*SyncRolePermissions) Check(lastRun map[string]interface{}) bool { return true } - -func rolePermissionMappingsToDatabaseMap(mappings []authz.RoleMapping, system bool) database.Map[[]string] { - out := make(database.Map[[]string], len(mappings)) - for _, m := range mappings { - if system == strings.HasPrefix(m.Role, "SYSTEM") { - out[m.Role] = m.Permissions - } - } - return out -} diff --git a/cmd/start/start.go b/cmd/start/start.go index 4091213d2d..12062951a9 100644 --- a/cmd/start/start.go +++ b/cmd/start/start.go @@ -92,6 +92,7 @@ import ( "github.com/zitadel/zitadel/internal/net" "github.com/zitadel/zitadel/internal/notification" "github.com/zitadel/zitadel/internal/query" + "github.com/zitadel/zitadel/internal/queue" "github.com/zitadel/zitadel/internal/static" es_v4 "github.com/zitadel/zitadel/internal/v2/eventstore" es_v4_pg "github.com/zitadel/zitadel/internal/v2/eventstore/postgres" @@ -143,10 +144,6 @@ type Server struct { func startZitadel(ctx context.Context, config *Config, masterKey string, server chan<- *Server) error { showBasicInformation(config) - // sink Server is stubbed out in production builds, see function's godoc. - closeSink := sink.StartServer() - defer closeSink() - i18n.MustLoadSupportedLanguagesFromDir() dbClient, err := database.Connect(config.Database, false) @@ -254,6 +251,10 @@ func startZitadel(ctx context.Context, config *Config, masterKey string, server } defer commands.Close(ctx) // wait for background jobs + // sink Server is stubbed out in production builds, see function's godoc. + closeSink := sink.StartServer(commands) + defer closeSink() + clock := clockpkg.New() actionsExecutionStdoutEmitter, err := logstore.NewEmitter[*record.ExecutionLog](ctx, clock, &logstore.EmitterConfig{Enabled: config.LogStore.Execution.Stdout.Enabled}, stdout.NewStdoutEmitter[*record.ExecutionLog]()) if err != nil { @@ -267,6 +268,13 @@ func startZitadel(ctx context.Context, config *Config, masterKey string, server actionsLogstoreSvc := logstore.New(queries, actionsExecutionDBEmitter, actionsExecutionStdoutEmitter) actions.SetLogstoreService(actionsLogstoreSvc) + q, err := queue.NewQueue(&queue.Config{ + Client: dbClient, + }) + if err != nil { + return err + } + notification.Register( ctx, config.Projections.Customizations["notifications"], @@ -289,9 +297,14 @@ func startZitadel(ctx context.Context, config *Config, masterKey string, server keys.OIDC, config.OIDC.DefaultBackChannelLogoutLifetime, dbClient, + q, ) notification.Start(ctx) + if err = q.Start(ctx); err != nil { + return err + } + router := mux.NewRouter() tlsConfig, err := config.TLS.Config() if err != nil { @@ -560,7 +573,7 @@ func startAPIs( if err := apis.RegisterService(ctx, oidc_v2beta.CreateServer(commands, queries, oidcServer, config.ExternalSecure)); err != nil { return nil, err } - if err := apis.RegisterService(ctx, oidc_v2.CreateServer(commands, queries, oidcServer, config.ExternalSecure)); err != nil { + if err := apis.RegisterService(ctx, oidc_v2.CreateServer(commands, queries, oidcServer, config.ExternalSecure, keys.OIDC)); err != nil { return nil, err } // After SAML provider so that the callback endpoint can be used diff --git a/console/angular.json b/console/angular.json index 278498ccd7..5564b2c428 100644 --- a/console/angular.json +++ b/console/angular.json @@ -63,7 +63,7 @@ { "type": "initial", "maximumWarning": "8mb", - "maximumError": "9mb" + "maximumError": "10mb" }, { "type": "anyComponentStyle", diff --git a/console/package.json b/console/package.json index fcf3a4bbf8..2c1d38da1b 100644 --- a/console/package.json +++ b/console/package.json @@ -24,6 +24,8 @@ "@angular/platform-browser-dynamic": "^16.2.5", "@angular/router": "^16.2.5", "@angular/service-worker": "^16.2.5", + "@connectrpc/connect": "^2.0.0", + "@connectrpc/connect-web": "^2.0.0", "@ctrl/ngx-codemirror": "^6.1.0", "@fortawesome/angular-fontawesome": "^0.13.0", "@fortawesome/fontawesome-svg-core": "^6.4.2", @@ -31,6 +33,8 @@ "@grpc/grpc-js": "^1.11.2", "@netlify/framework-info": "^9.8.13", "@ngx-translate/core": "^15.0.0", + "@zitadel/client": "^1.0.6", + "@zitadel/proto": "^1.0.3", "angular-oauth2-oidc": "^15.0.1", "angularx-qrcode": "^16.0.0", "buffer": "^6.0.3", diff --git a/console/src/app/app.component.html b/console/src/app/app.component.html index 5b31b33dc4..9907e233e1 100644 --- a/console/src/app/app.component.html +++ b/console/src/app/app.component.html @@ -1,9 +1,8 @@
- + @@ -12,9 +11,8 @@ id="mainnav" class="nav" [ngClass]="{ shadow: yoffset > 60 }" - *ngIf="user && user !== {}" [org]="org" - [user]="$any(user)" + [user]="user" [isDarkTheme]="componentCssClass === 'dark-theme'" > diff --git a/console/src/app/app.component.ts b/console/src/app/app.component.ts index 24dedf2b5d..bd46e30cee 100644 --- a/console/src/app/app.component.ts +++ b/console/src/app/app.component.ts @@ -1,14 +1,14 @@ import { BreakpointObserver } from '@angular/cdk/layout'; import { OverlayContainer } from '@angular/cdk/overlay'; import { DOCUMENT, ViewportScroller } from '@angular/common'; -import { Component, HostBinding, HostListener, Inject, OnDestroy, ViewChild } from '@angular/core'; +import { Component, DestroyRef, HostBinding, HostListener, Inject, OnDestroy, ViewChild } from '@angular/core'; import { MatIconRegistry } from '@angular/material/icon'; import { MatDrawer } from '@angular/material/sidenav'; import { DomSanitizer } from '@angular/platform-browser'; import { ActivatedRoute, Router, RouterOutlet } from '@angular/router'; import { LangChangeEvent, TranslateService } from '@ngx-translate/core'; -import { Observable, of, Subject } from 'rxjs'; -import { filter, map, takeUntil } from 'rxjs/operators'; +import { Observable, of, Subject, switchMap } from 'rxjs'; +import { filter, map, startWith, takeUntil, tap } from 'rxjs/operators'; import { accountCard, adminLineAnimation, navAnimations, routeAnimations, toolbarAnimation } from './animations'; import { Org } from './proto/generated/zitadel/org_pb'; @@ -21,6 +21,7 @@ import { ThemeService } from './services/theme.service'; import { UpdateService } from './services/update.service'; import { fallbackLanguage, supportedLanguages, supportedLanguagesRegexp } from './utils/language'; import { PosthogService } from './services/posthog.service'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ selector: 'cnsl-root', @@ -28,7 +29,7 @@ import { PosthogService } from './services/posthog.service'; styleUrls: ['./app.component.scss'], animations: [toolbarAnimation, ...navAnimations, accountCard, routeAnimations, adminLineAnimation], }) -export class AppComponent implements OnDestroy { +export class AppComponent { @ViewChild('drawer') public drawer!: MatDrawer; public isHandset$: Observable = this.breakpointObserver.observe('(max-width: 599px)').pipe( map((result) => { @@ -48,8 +49,6 @@ export class AppComponent implements OnDestroy { public showProjectSection: boolean = false; - private destroy$: Subject = new Subject(); - public language: string = 'en'; public privacyPolicy!: PrivacyPolicy.AsObject; constructor( @@ -70,6 +69,7 @@ export class AppComponent implements OnDestroy { private activatedRoute: ActivatedRoute, @Inject(DOCUMENT) private document: Document, private posthog: PosthogService, + private readonly destroyRef: DestroyRef, ) { console.log( '%cWait!', @@ -199,42 +199,43 @@ export class AppComponent implements OnDestroy { this.getProjectCount(); - this.authService.activeOrgChanged.pipe(takeUntil(this.destroy$)).subscribe((org) => { + this.authService.activeOrgChanged.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((org) => { if (org) { this.org = org; this.getProjectCount(); } }); - this.activatedRoute.queryParams.pipe(filter((params) => !!params['org'])).subscribe((params) => { - const { org } = params; - this.authService.getActiveOrg(org); - }); + this.activatedRoute.queryParamMap + .pipe( + map((params) => params.get('org')), + filter(Boolean), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe((org) => this.authService.getActiveOrg(org)); - this.authenticationService.authenticationChanged.pipe(takeUntil(this.destroy$)).subscribe((authenticated) => { - if (authenticated) { - this.authService - .getActiveOrg() - .then(async (org) => { - this.org = org; - // TODO add when console storage is implemented - // this.startIntroWorkflow(); - }) - .catch((error) => { - console.error(error); - this.router.navigate(['/users/me']); - }); - } - }); + this.authenticationService.authenticationChanged + .pipe( + filter(Boolean), + switchMap(() => this.authService.getActiveOrg()), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe({ + next: (org) => (this.org = org), + error: async (err) => { + console.error(err); + return this.router.navigate(['/users/me']); + }, + }); this.isDarkTheme = this.themeService.isDarkTheme; - this.isDarkTheme.pipe(takeUntil(this.destroy$)).subscribe((dark) => { + this.isDarkTheme.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((dark) => { const theme = dark ? 'dark-theme' : 'light-theme'; this.onSetTheme(theme); this.setFavicon(theme); }); - this.translate.onLangChange.pipe(takeUntil(this.destroy$)).subscribe((language: LangChangeEvent) => { + this.translate.onLangChange.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((language: LangChangeEvent) => { this.document.documentElement.lang = language.lang; this.language = language.lang; }); @@ -254,11 +255,6 @@ export class AppComponent implements OnDestroy { // }, 1000); // } - public ngOnDestroy(): void { - this.destroy$.next(); - this.destroy$.complete(); - } - public prepareRoute(outlet: RouterOutlet): boolean { return outlet && outlet.activatedRouteData && outlet.activatedRouteData['animation']; } @@ -275,7 +271,7 @@ export class AppComponent implements OnDestroy { const currentUrl = this.router.url; this.router.navigateByUrl('/', { skipLocationChange: true }).then(() => { // We use navigateByUrl as our urls may have queryParams - this.router.navigateByUrl(currentUrl); + this.router.navigateByUrl(currentUrl).then(); }); } @@ -283,18 +279,16 @@ export class AppComponent implements OnDestroy { this.translate.addLangs(supportedLanguages); this.translate.setDefaultLang(fallbackLanguage); - this.authService.userSubject.pipe(takeUntil(this.destroy$)).subscribe((userprofile) => { - if (userprofile) { - const cropped = navigator.language.split('-')[0] ?? fallbackLanguage; - const fallbackLang = cropped.match(supportedLanguagesRegexp) ? cropped : fallbackLanguage; + this.authService.user.pipe(filter(Boolean), takeUntilDestroyed(this.destroyRef)).subscribe((userprofile) => { + const cropped = navigator.language.split('-')[0] ?? fallbackLanguage; + const fallbackLang = cropped.match(supportedLanguagesRegexp) ? cropped : fallbackLanguage; - const lang = userprofile?.human?.profile?.preferredLanguage.match(supportedLanguagesRegexp) - ? userprofile.human.profile?.preferredLanguage - : fallbackLang; - this.translate.use(lang); - this.language = lang; - this.document.documentElement.lang = lang; - } + const lang = userprofile?.human?.profile?.preferredLanguage.match(supportedLanguagesRegexp) + ? userprofile.human.profile?.preferredLanguage + : fallbackLang; + this.translate.use(lang); + this.language = lang; + this.document.documentElement.lang = lang; }); } @@ -308,7 +302,7 @@ export class AppComponent implements OnDestroy { } private setFavicon(theme: string): void { - this.authService.labelpolicy.pipe(takeUntil(this.destroy$)).subscribe((lP) => { + this.authService.labelpolicy$.pipe(startWith(undefined), takeUntilDestroyed(this.destroyRef)).subscribe((lP) => { if (theme === 'dark-theme' && lP?.iconUrlDark) { // Check if asset url is stable, maybe it was deleted but still wasn't applied fetch(lP.iconUrlDark).then((response) => { diff --git a/console/src/app/components/features/features.component.ts b/console/src/app/components/features/features.component.ts index 327e9d2792..899670c95e 100644 --- a/console/src/app/components/features/features.component.ts +++ b/console/src/app/components/features/features.component.ts @@ -1,28 +1,24 @@ import { CommonModule } from '@angular/common'; -import { Component, OnDestroy } from '@angular/core'; +import { Component } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; import { MatButtonToggleModule } from '@angular/material/button-toggle'; import { MatCheckboxModule } from '@angular/material/checkbox'; -import { MatDialog } from '@angular/material/dialog'; import { MatIconModule } from '@angular/material/icon'; import { MatTooltipModule } from '@angular/material/tooltip'; import { TranslateModule } from '@ngx-translate/core'; -import { BehaviorSubject, Subject } from 'rxjs'; import { HasRoleModule } from 'src/app/directives/has-role/has-role.module'; import { CardModule } from 'src/app/modules/card/card.module'; -import { DisplayJsonDialogComponent } from 'src/app/modules/display-json-dialog/display-json-dialog.component'; import { InfoSectionModule } from 'src/app/modules/info-section/info-section.module'; import { HasRolePipeModule } from 'src/app/pipes/has-role-pipe/has-role-pipe.module'; -import { Event } from 'src/app/proto/generated/zitadel/event_pb'; import { Source } from 'src/app/proto/generated/zitadel/feature/v2beta/feature_pb'; -import { - GetInstanceFeaturesResponse, - SetInstanceFeaturesRequest, -} from 'src/app/proto/generated/zitadel/feature/v2beta/instance_pb'; import { Breadcrumb, BreadcrumbService, BreadcrumbType } from 'src/app/services/breadcrumb.service'; import { FeatureService } from 'src/app/services/feature.service'; import { ToastService } from 'src/app/services/toast.service'; +import { + GetInstanceFeaturesResponse, + SetInstanceFeaturesRequest, +} from 'src/app/proto/generated/zitadel/feature/v2/instance_pb'; enum ToggleState { ENABLED = 'ENABLED', @@ -61,21 +57,17 @@ type ToggleStates = { templateUrl: './features.component.html', styleUrls: ['./features.component.scss'], }) -export class FeaturesComponent implements OnDestroy { - private destroy$: Subject = new Subject(); +export class FeaturesComponent { + protected featureData: GetInstanceFeaturesResponse.AsObject | undefined; - public _loading: BehaviorSubject = new BehaviorSubject(false); - public featureData: GetInstanceFeaturesResponse.AsObject | undefined = undefined; - - public toggleStates: ToggleStates | undefined = undefined; - public Source: any = Source; - public ToggleState: any = ToggleState; + protected toggleStates: ToggleStates | undefined; + protected Source: any = Source; + protected ToggleState: any = ToggleState; constructor( private featureService: FeatureService, private breadcrumbService: BreadcrumbService, private toast: ToastService, - private dialog: MatDialog, ) { const breadcrumbs = [ new Breadcrumb({ @@ -89,20 +81,6 @@ export class FeaturesComponent implements OnDestroy { this.getFeatures(true); } - ngOnDestroy(): void { - this.destroy$.next(); - this.destroy$.complete(); - } - - public openDialog(event: Event): void { - this.dialog.open(DisplayJsonDialogComponent, { - data: { - event: event, - }, - width: '450px', - }); - } - public validateAndSave() { this.featureService.resetInstanceFeatures().then(() => { const req = new SetInstanceFeaturesRequest(); diff --git a/console/src/app/directives/has-role/has-role.directive.ts b/console/src/app/directives/has-role/has-role.directive.ts index b58e1f3a10..9ba21c1dd2 100644 --- a/console/src/app/directives/has-role/has-role.directive.ts +++ b/console/src/app/directives/has-role/has-role.directive.ts @@ -1,18 +1,17 @@ -import { Directive, Input, OnDestroy, TemplateRef, ViewContainerRef } from '@angular/core'; -import { Subject, takeUntil } from 'rxjs'; +import { DestroyRef, Directive, Input, TemplateRef, ViewContainerRef } from '@angular/core'; import { GrpcAuthService } from 'src/app/services/grpc-auth.service'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Directive({ selector: '[cnslHasRole]', }) -export class HasRoleDirective implements OnDestroy { - private destroy$: Subject = new Subject(); +export class HasRoleDirective { private hasView: boolean = false; @Input() public set hasRole(roles: string[] | RegExp[] | undefined) { if (roles && roles.length > 0) { this.authService .isAllowed(roles) - .pipe(takeUntil(this.destroy$)) + .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((isAllowed) => { if (isAllowed && !this.hasView) { if (this.viewContainerRef.length !== 0) { @@ -38,10 +37,6 @@ export class HasRoleDirective implements OnDestroy { private authService: GrpcAuthService, protected templateRef: TemplateRef, protected viewContainerRef: ViewContainerRef, + private readonly destroyRef: DestroyRef, ) {} - - ngOnDestroy(): void { - this.destroy$.next(); - this.destroy$.complete(); - } } diff --git a/console/src/app/modules/accounts-card/accounts-card.component.ts b/console/src/app/modules/accounts-card/accounts-card.component.ts index 617a41bf6d..2676a5bcf5 100644 --- a/console/src/app/modules/accounts-card/accounts-card.component.ts +++ b/console/src/app/modules/accounts-card/accounts-card.component.ts @@ -4,6 +4,7 @@ import { AuthConfig } from 'angular-oauth2-oidc'; import { Session, User, UserState } from 'src/app/proto/generated/zitadel/user_pb'; import { AuthenticationService } from 'src/app/services/authentication.service'; import { GrpcAuthService } from 'src/app/services/grpc-auth.service'; +import { toSignal } from '@angular/core/rxjs-interop'; @Component({ selector: 'cnsl-accounts-card', @@ -18,6 +19,8 @@ export class AccountsCardComponent implements OnInit { public sessions: Session.AsObject[] = []; public loadingUsers: boolean = false; public UserState: any = UserState; + private labelpolicy = toSignal(this.userService.labelpolicy$, { initialValue: undefined }); + constructor( public authService: AuthenticationService, private router: Router, @@ -68,7 +71,7 @@ export class AccountsCardComponent implements OnInit { } public logout(): void { - const lP = JSON.stringify(this.userService.labelpolicy.getValue()); + const lP = JSON.stringify(this.labelpolicy()); localStorage.setItem('labelPolicyOnSignout', lP); this.authService.signout(); diff --git a/console/src/app/modules/filter-org/filter-org.component.ts b/console/src/app/modules/filter-org/filter-org.component.ts index 8e100971d0..220b219358 100644 --- a/console/src/app/modules/filter-org/filter-org.component.ts +++ b/console/src/app/modules/filter-org/filter-org.component.ts @@ -1,4 +1,4 @@ -import { Component, OnInit } from '@angular/core'; +import { Component, DestroyRef, OnInit } from '@angular/core'; import { MatCheckboxChange } from '@angular/material/checkbox'; import { ActivatedRoute, Router } from '@angular/router'; import { take } from 'rxjs'; @@ -27,9 +27,10 @@ export class FilterOrgComponent extends FilterComponent implements OnInit { constructor( router: Router, + destroyRef: DestroyRef, protected override route: ActivatedRoute, ) { - super(router, route); + super(router, route, destroyRef); } ngOnInit(): void { diff --git a/console/src/app/modules/filter-project/filter-project.component.ts b/console/src/app/modules/filter-project/filter-project.component.ts index b884024c2c..92556d311d 100644 --- a/console/src/app/modules/filter-project/filter-project.component.ts +++ b/console/src/app/modules/filter-project/filter-project.component.ts @@ -1,4 +1,4 @@ -import { Component, OnInit } from '@angular/core'; +import { Component, DestroyRef, OnInit } from '@angular/core'; import { MatCheckboxChange } from '@angular/material/checkbox'; import { ActivatedRoute, Router } from '@angular/router'; import { take } from 'rxjs'; @@ -23,8 +23,8 @@ export class FilterProjectComponent extends FilterComponent implements OnInit { public searchQueries: ProjectQuery[] = []; public states: ProjectState[] = [ProjectState.PROJECT_STATE_ACTIVE, ProjectState.PROJECT_STATE_INACTIVE]; - constructor(router: Router, route: ActivatedRoute) { - super(router, route); + constructor(router: Router, route: ActivatedRoute, destroyRef: DestroyRef) { + super(router, route, destroyRef); } ngOnInit(): void { diff --git a/console/src/app/modules/filter-user-grants/filter-user-grants.component.ts b/console/src/app/modules/filter-user-grants/filter-user-grants.component.ts index 3c17e8c208..dccaed13e5 100644 --- a/console/src/app/modules/filter-user-grants/filter-user-grants.component.ts +++ b/console/src/app/modules/filter-user-grants/filter-user-grants.component.ts @@ -1,4 +1,4 @@ -import { Component, OnInit } from '@angular/core'; +import { Component, DestroyRef, OnInit } from '@angular/core'; import { MatCheckboxChange } from '@angular/material/checkbox'; import { ActivatedRoute, Router } from '@angular/router'; import { take } from 'rxjs'; @@ -29,8 +29,8 @@ export class FilterUserGrantsComponent extends FilterComponent implements OnInit public SubQuery: any = SubQuery; public searchQueries: UserGrantQuery[] = []; - constructor(router: Router, route: ActivatedRoute) { - super(router, route); + constructor(router: Router, route: ActivatedRoute, destroyRef: DestroyRef) { + super(router, route, destroyRef); } ngOnInit(): void { diff --git a/console/src/app/modules/filter-user/filter-user.component.html b/console/src/app/modules/filter-user/filter-user.component.html index c5d3d9a820..907ea6d18d 100644 --- a/console/src/app/modules/filter-user/filter-user.component.html +++ b/console/src/app/modules/filter-user/filter-user.component.html @@ -1,4 +1,4 @@ - +
{ - const { filter } = params; - if (filter) { - const stringifiedFilters = filter as string; + this.route.queryParamMap + .pipe( + take(1), + map((params) => params.get('filter')), + filter(Boolean), + ) + .subscribe((stringifiedFilters) => { const filters: UserSearchQuery.AsObject[] = JSON.parse(stringifiedFilters) as UserSearchQuery.AsObject[]; const userQueries = filters.map((filter) => { @@ -94,8 +97,7 @@ export class FilterUserComponent extends FilterComponent implements OnInit { this.filterChanged.emit(this.searchQueries ? this.searchQueries : []); // this.showFilter = true; // this.filterOpen.emit(true); - } - }); + }); } public changeCheckbox(subquery: SubQuery, event: MatCheckboxChange) { diff --git a/console/src/app/modules/filter/filter.component.ts b/console/src/app/modules/filter/filter.component.ts index ce2cc15c08..dac94525d9 100644 --- a/console/src/app/modules/filter/filter.component.ts +++ b/console/src/app/modules/filter/filter.component.ts @@ -1,7 +1,6 @@ import { ConnectedPosition, ConnectionPositionPair } from '@angular/cdk/overlay'; -import { Component, EventEmitter, Input, OnDestroy, Output } from '@angular/core'; +import { Component, DestroyRef, EventEmitter, Input, Output } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; -import { Observable, Subject, takeUntil } from 'rxjs'; import { SearchQuery as MemberSearchQuery } from 'src/app/proto/generated/zitadel/member_pb'; import { TextQueryMethod } from 'src/app/proto/generated/zitadel/object_pb'; import { OrgQuery } from 'src/app/proto/generated/zitadel/org_pb'; @@ -9,6 +8,7 @@ import { ProjectQuery } from 'src/app/proto/generated/zitadel/project_pb'; import { SearchQuery as UserSearchQuery, UserGrantQuery } from 'src/app/proto/generated/zitadel/user_pb'; import { ActionKeysType } from '../action-keys/action-keys.component'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; type FilterSearchQuery = UserSearchQuery | MemberSearchQuery | UserGrantQuery | ProjectQuery | OrgQuery; type FilterSearchQueryAsObject = @@ -23,7 +23,7 @@ type FilterSearchQueryAsObject = templateUrl: './filter.component.html', styleUrls: ['./filter.component.scss'], }) -export class FilterComponent implements OnDestroy { +export class FilterComponent { @Output() public filterChanged: EventEmitter = new EventEmitter(); @Output() public filterOpen: EventEmitter = new EventEmitter(false); @@ -32,9 +32,6 @@ export class FilterComponent implements OnDestroy { @Input() public queryCount: number = 0; - private destroy$: Subject = new Subject(); - public filterChanged$: Observable = this.filterChanged.asObservable(); - public showFilter: boolean = false; public methods: TextQueryMethod[] = [ TextQueryMethod.TEXT_QUERY_METHOD_CONTAINS_IGNORE_CASE, @@ -59,17 +56,13 @@ export class FilterComponent implements OnDestroy { this.trigger.emit(); } - public ngOnDestroy(): void { - this.destroy$.next(); - this.destroy$.complete(); - } - constructor( private router: Router, protected route: ActivatedRoute, + destroyRef: DestroyRef, ) { const changes$ = this.filterChanged.asObservable(); - changes$.pipe(takeUntil(this.destroy$)).subscribe((queries) => { + changes$.pipe(takeUntilDestroyed(destroyRef)).subscribe((queries) => { const filters: Array | undefined = queries ?.map((q) => q.toObject()) .map((query) => @@ -81,15 +74,17 @@ export class FilterComponent implements OnDestroy { ); if (filters && Object.keys(filters)) { - this.router.navigate([], { - relativeTo: this.route, - queryParams: { - ['filter']: JSON.stringify(filters), - }, - replaceUrl: true, - queryParamsHandling: 'merge', - skipLocationChange: false, - }); + this.router + .navigate([], { + relativeTo: this.route, + queryParams: { + ['filter']: JSON.stringify(filters), + }, + replaceUrl: true, + queryParamsHandling: 'merge', + skipLocationChange: false, + }) + .then(); } }); } diff --git a/console/src/app/modules/footer/footer.component.html b/console/src/app/modules/footer/footer.component.html index 26d863d129..b9eda2d7db 100644 --- a/console/src/app/modules/footer/footer.component.html +++ b/console/src/app/modules/footer/footer.component.html @@ -1,6 +1,6 @@