diff --git a/cmd/setup/68.go b/cmd/setup/68.go new file mode 100644 index 000000000..03423aa66 --- /dev/null +++ b/cmd/setup/68.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 68.sql + targetAddPayloadTypeAndKeyFingerprintColumn string +) + +type TargetAddPayloadTypeColumn struct { + dbClient *database.DB +} + +func (mig *TargetAddPayloadTypeColumn) Execute(ctx context.Context, _ eventstore.Event) error { + _, err := mig.dbClient.ExecContext(ctx, targetAddPayloadTypeAndKeyFingerprintColumn) + return err +} + +func (mig *TargetAddPayloadTypeColumn) String() string { + return "68_target2_add_payload_type_authn_key2_fingerprint_expiration" +} diff --git a/cmd/setup/68.sql b/cmd/setup/68.sql new file mode 100644 index 000000000..7937bda74 --- /dev/null +++ b/cmd/setup/68.sql @@ -0,0 +1,8 @@ +ALTER TABLE IF EXISTS projections.targets2 +ADD COLUMN IF NOT EXISTS payload_type smallint default 0; + +ALTER TABLE IF EXISTS projections.authn_keys2 +ADD COLUMN IF NOT EXISTS fingerprint text; + +ALTER TABLE IF EXISTS projections.authn_keys2 +ALTER COLUMN expiration DROP NOT NULL; diff --git a/cmd/setup/config.go b/cmd/setup/config.go index 33a39c4fe..36e15f695 100644 --- a/cmd/setup/config.go +++ b/cmd/setup/config.go @@ -164,6 +164,7 @@ type Steps struct { s65FixUserMetadata5Index *FixUserMetadata5Index s66SessionRecoveryCodeCheckedAt *SessionRecoveryCodeCheckedAt s67SyncMemberRoleFields *SyncMemberRoleFields + s68TargetAddPayloadTypeColumn *TargetAddPayloadTypeColumn } func MustNewSteps(v *viper.Viper) *Steps { diff --git a/cmd/setup/setup.go b/cmd/setup/setup.go index 02b8084f3..5c7d115da 100644 --- a/cmd/setup/setup.go +++ b/cmd/setup/setup.go @@ -225,6 +225,7 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string) steps.s65FixUserMetadata5Index = &FixUserMetadata5Index{dbClient: dbClient} steps.s66SessionRecoveryCodeCheckedAt = &SessionRecoveryCodeCheckedAt{dbClient: dbClient} steps.s67SyncMemberRoleFields = &SyncMemberRoleFields{dbClient: dbClient} + steps.s68TargetAddPayloadTypeColumn = &TargetAddPayloadTypeColumn{dbClient: dbClient} err = projection.Create(ctx, dbClient, eventstoreClient, config.Projections, nil, nil, nil) logging.OnError(err).Fatal("unable to start projections") @@ -342,6 +343,7 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string) steps.s48Apps7SAMLConfigsLoginVersion, steps.s59SetupWebkeys, // this step needs commands. steps.s66SessionRecoveryCodeCheckedAt, + steps.s68TargetAddPayloadTypeColumn, } { setupErr = executeMigration(ctx, eventstoreClient, step, "migration failed") if setupErr != nil { diff --git a/cmd/setup/transactional_tables.go b/cmd/setup/transactional_tables.go index ca5df0a0a..b159a0fda 100644 --- a/cmd/setup/transactional_tables.go +++ b/cmd/setup/transactional_tables.go @@ -19,7 +19,6 @@ func (mig *TransactionalTables) Execute(ctx context.Context, _ eventstore.Event) if err != nil { return err } - return pool.Migrate(ctx) } diff --git a/cmd/start/start.go b/cmd/start/start.go index 235f99ec6..c712af057 100644 --- a/cmd/start/start.go +++ b/cmd/start/start.go @@ -331,6 +331,7 @@ func startZitadel(ctx context.Context, config *Config, masterKey string, server config.Executions, q, keys.Target, + queries.GetActiveSigningWebKey, ) execution.Start(ctx) diff --git a/console/src/app/modules/actions-two/actions-two-actions/actions-two-actions-table/actions-two-actions-table.component.ts b/console/src/app/modules/actions-two/actions-two-actions/actions-two-actions-table/actions-two-actions-table.component.ts index 15b580df2..56952f2b0 100644 --- a/console/src/app/modules/actions-two/actions-two-actions/actions-two-actions-table/actions-two-actions-table.component.ts +++ b/console/src/app/modules/actions-two/actions-two-actions/actions-two-actions-table/actions-two-actions-table.component.ts @@ -2,7 +2,7 @@ import { ChangeDetectionStrategy, Component, computed, effect, EventEmitter, Inp import { combineLatestWith, Observable, ReplaySubject } from 'rxjs'; import { filter, map, startWith } from 'rxjs/operators'; import { MatTableDataSource } from '@angular/material/table'; -import { Target } from '@zitadel/proto/zitadel/action/v2beta/target_pb'; +import { Target } from '@zitadel/proto/zitadel/action/v2/target_pb'; import { toSignal } from '@angular/core/rxjs-interop'; import { CorrectlyTypedExecution } from '../../actions-two-add-action/actions-two-add-action-dialog.component'; diff --git a/console/src/app/modules/actions-two/actions-two-actions/actions-two-actions.component.ts b/console/src/app/modules/actions-two/actions-two-actions/actions-two-actions.component.ts index efa0e2b31..083f12b67 100644 --- a/console/src/app/modules/actions-two/actions-two-actions/actions-two-actions.component.ts +++ b/console/src/app/modules/actions-two/actions-two-actions/actions-two-actions.component.ts @@ -13,10 +13,10 @@ import { } from '../actions-two-add-action/actions-two-add-action-dialog.component'; import { MatDialog } from '@angular/material/dialog'; import { MessageInitShape } from '@bufbuild/protobuf'; -import { SetExecutionRequestSchema } from '@zitadel/proto/zitadel/action/v2beta/action_service_pb'; -import { Target } from '@zitadel/proto/zitadel/action/v2beta/target_pb'; +import { SetExecutionRequestSchema } from '@zitadel/proto/zitadel/action/v2/action_service_pb'; +import { Target } from '@zitadel/proto/zitadel/action/v2/target_pb'; import { InfoSectionType } from '../../info-section/info-section.component'; -import { ExecutionFieldName } from '@zitadel/proto/zitadel/action/v2beta/query_pb'; +import { ExecutionFieldName } from '@zitadel/proto/zitadel/action/v2/query_pb'; @Component({ selector: 'cnsl-actions-two-actions', diff --git a/console/src/app/modules/actions-two/actions-two-add-action/actions-two-add-action-condition/actions-two-add-action-condition.component.ts b/console/src/app/modules/actions-two/actions-two-add-action/actions-two-add-action-condition/actions-two-add-action-condition.component.ts index bde7a919b..85408d5b9 100644 --- a/console/src/app/modules/actions-two/actions-two-add-action/actions-two-add-action-condition/actions-two-add-action-condition.component.ts +++ b/console/src/app/modules/actions-two/actions-two-add-action/actions-two-add-action-condition/actions-two-add-action-condition.component.ts @@ -14,7 +14,7 @@ import { ToastService } from 'src/app/services/toast.service'; import { MatCheckboxModule } from '@angular/material/checkbox'; import { atLeastOneFieldValidator, requiredValidator } from 'src/app/modules/form-field/validators/validators'; import { Message } from '@bufbuild/protobuf'; -import { Condition } from '@zitadel/proto/zitadel/action/v2beta/execution_pb'; +import { Condition } from '@zitadel/proto/zitadel/action/v2/execution_pb'; import { distinctUntilChanged, startWith, takeUntil } from 'rxjs/operators'; import { CreateQueryResult } from '@tanstack/angular-query-experimental'; import { toSignal } from '@angular/core/rxjs-interop'; diff --git a/console/src/app/modules/actions-two/actions-two-add-action/actions-two-add-action-dialog.component.ts b/console/src/app/modules/actions-two/actions-two-add-action/actions-two-add-action-dialog.component.ts index 77a1e5b1d..1e277df4e 100644 --- a/console/src/app/modules/actions-two/actions-two-add-action/actions-two-add-action-dialog.component.ts +++ b/console/src/app/modules/actions-two/actions-two-add-action/actions-two-add-action-dialog.component.ts @@ -10,9 +10,9 @@ import { } from './actions-two-add-action-condition/actions-two-add-action-condition.component'; import { ActionsTwoAddActionTargetComponent } from './actions-two-add-action-target/actions-two-add-action-target.component'; import { CommonModule } from '@angular/common'; -import { Condition, Execution } from '@zitadel/proto/zitadel/action/v2beta/execution_pb'; +import { Condition, Execution } from '@zitadel/proto/zitadel/action/v2/execution_pb'; import { Subject } from 'rxjs'; -import { SetExecutionRequestSchema } from '@zitadel/proto/zitadel/action/v2beta/action_service_pb'; +import { SetExecutionRequestSchema } from '@zitadel/proto/zitadel/action/v2/action_service_pb'; enum Page { Type, diff --git a/console/src/app/modules/actions-two/actions-two-add-action/actions-two-add-action-target/actions-two-add-action-target.component.ts b/console/src/app/modules/actions-two/actions-two-add-action/actions-two-add-action-target/actions-two-add-action-target.component.ts index 6f5666210..ac724f021 100644 --- a/console/src/app/modules/actions-two/actions-two-add-action/actions-two-add-action-target/actions-two-add-action-target.component.ts +++ b/console/src/app/modules/actions-two/actions-two-add-action/actions-two-add-action-target/actions-two-add-action-target.component.ts @@ -21,7 +21,7 @@ import { ToastService } from 'src/app/services/toast.service'; import { MatAutocompleteModule } from '@angular/material/autocomplete'; import { InputModule } from 'src/app/modules/input/input.module'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; -import { Target } from '@zitadel/proto/zitadel/action/v2beta/target_pb'; +import { Target } from '@zitadel/proto/zitadel/action/v2/target_pb'; import { MatSelectModule } from '@angular/material/select'; import { ActionConditionPipeModule } from 'src/app/pipes/action-condition-pipe/action-condition-pipe.module'; import { MatTableDataSource, MatTableModule } from '@angular/material/table'; diff --git a/console/src/app/modules/actions-two/actions-two-add-target/actions-two-add-target-dialog.component.html b/console/src/app/modules/actions-two/actions-two-add-target/actions-two-add-target-dialog.component.html index 717ee8f85..f0d2d0813 100644 --- a/console/src/app/modules/actions-two/actions-two-add-target/actions-two-add-target-dialog.component.html +++ b/console/src/app/modules/actions-two/actions-two-add-target/actions-two-add-target-dialog.component.html @@ -31,6 +31,18 @@ + + {{ 'ACTIONSTWO.TARGET.CREATE.PAYLOAD_TYPE' | translate }} + + + {{ 'ACTIONSTWO.TARGET.CREATE.PAYLOAD_TYPES.' + type | translate }} + + + + {{ 'ACTIONSTWO.TARGET.CREATE.PAYLOAD_TYPE_DESCRIPTION' | translate }} + + + {{ 'ACTIONSTWO.TARGET.CREATE.TIMEOUT' | translate }} diff --git a/console/src/app/modules/actions-two/actions-two-add-target/actions-two-add-target-dialog.component.ts b/console/src/app/modules/actions-two/actions-two-add-target/actions-two-add-target-dialog.component.ts index 956060078..2b29539ce 100644 --- a/console/src/app/modules/actions-two/actions-two-add-target/actions-two-add-target-dialog.component.ts +++ b/console/src/app/modules/actions-two/actions-two-add-target/actions-two-add-target-dialog.component.ts @@ -10,11 +10,9 @@ import { requiredValidator } from '../../form-field/validators/validators'; import { MessageInitShape } from '@bufbuild/protobuf'; import { DurationSchema } from '@bufbuild/protobuf/wkt'; import { MatSelectModule } from '@angular/material/select'; -import { Target } from '@zitadel/proto/zitadel/action/v2beta/target_pb'; -import { - CreateTargetRequestSchema, - UpdateTargetRequestSchema, -} from '@zitadel/proto/zitadel/action/v2beta/action_service_pb'; +import { PayloadType, Target } from '@zitadel/proto/zitadel/action/v2/target_pb'; +import { CreateTargetRequestSchema, UpdateTargetRequestSchema } from '@zitadel/proto/zitadel/action/v2/action_service_pb'; +import { getEnumKeyFromValue, getEnumKeys } from 'src/app/utils/enum.utils'; type TargetTypes = ActionTwoAddTargetDialogComponent['targetTypes'][number]; @@ -36,6 +34,7 @@ type TargetTypes = ActionTwoAddTargetDialogComponent['targetTypes'][number]; export class ActionTwoAddTargetDialogComponent { protected readonly targetTypes = ['restCall', 'restWebhook', 'restAsync'] as const; protected readonly targetForm: ReturnType; + protected readonly payloadTypes = getEnumKeys(PayloadType); constructor( private fb: FormBuilder, @@ -56,6 +55,8 @@ export class ActionTwoAddTargetDialogComponent { endpoint: data.target.endpoint, timeout: Number(data.target.timeout?.seconds), type: this.data.target?.targetType?.case ?? 'restWebhook', + payloadType: + getEnumKeyFromValue(PayloadType, this.data.target?.payloadType ?? PayloadType.UNSPECIFIED) || this.payloadTypes[0], interruptOnError: data.target.targetType.case === 'restWebhook' || data.target.targetType.case === 'restCall' ? data.target.targetType.value.interruptOnError @@ -70,6 +71,10 @@ export class ActionTwoAddTargetDialogComponent { nonNullable: true, validators: [requiredValidator], }), + payloadType: new FormControl<(typeof this.payloadTypes)[number]>(this.payloadTypes[0], { + nonNullable: true, + validators: [requiredValidator], + }), endpoint: new FormControl('', { nonNullable: true, validators: [requiredValidator] }), timeout: new FormControl(10, { nonNullable: true, validators: [requiredValidator] }), interruptOnError: new FormControl(false, { nonNullable: true }), @@ -81,7 +86,7 @@ export class ActionTwoAddTargetDialogComponent { return; } - const { type, name, endpoint, timeout, interruptOnError } = this.targetForm.getRawValue(); + const { type, name, endpoint, timeout, interruptOnError, payloadType } = this.targetForm.getRawValue(); const timeoutDuration: MessageInitShape = { seconds: BigInt(timeout), @@ -100,6 +105,7 @@ export class ActionTwoAddTargetDialogComponent { endpoint, timeout: timeoutDuration, targetType, + payloadType: PayloadType[payloadType], }; this.dialogRef.close( diff --git a/console/src/app/modules/actions-two/actions-two-targets/actions-two-targets-table/actions-two-targets-table.component.ts b/console/src/app/modules/actions-two/actions-two-targets/actions-two-targets-table/actions-two-targets-table.component.ts index 6004bb967..d23633a65 100644 --- a/console/src/app/modules/actions-two/actions-two-targets/actions-two-targets-table/actions-two-targets-table.component.ts +++ b/console/src/app/modules/actions-two/actions-two-targets/actions-two-targets-table/actions-two-targets-table.component.ts @@ -2,7 +2,7 @@ import { ChangeDetectionStrategy, Component, effect, EventEmitter, Input, Output import { ReplaySubject } from 'rxjs'; import { filter, startWith } from 'rxjs/operators'; import { MatTableDataSource } from '@angular/material/table'; -import { Target } from '@zitadel/proto/zitadel/action/v2beta/target_pb'; +import { Target } from '@zitadel/proto/zitadel/action/v2/target_pb'; import { toSignal } from '@angular/core/rxjs-interop'; @Component({ diff --git a/console/src/app/modules/actions-two/actions-two-targets/actions-two-targets.component.ts b/console/src/app/modules/actions-two/actions-two-targets/actions-two-targets.component.ts index 7da8f1d3f..cfcc8c674 100644 --- a/console/src/app/modules/actions-two/actions-two-targets/actions-two-targets.component.ts +++ b/console/src/app/modules/actions-two/actions-two-targets/actions-two-targets.component.ts @@ -7,11 +7,8 @@ import { catchError, map, startWith, switchMap } from 'rxjs/operators'; import { MatDialog } from '@angular/material/dialog'; import { ActionTwoAddTargetDialogComponent } from '../actions-two-add-target/actions-two-add-target-dialog.component'; import { MessageInitShape } from '@bufbuild/protobuf'; -import { Target } from '@zitadel/proto/zitadel/action/v2beta/target_pb'; -import { - CreateTargetRequestSchema, - UpdateTargetRequestSchema, -} from '@zitadel/proto/zitadel/action/v2beta/action_service_pb'; +import { Target } from '@zitadel/proto/zitadel/action/v2/target_pb'; +import { CreateTargetRequestSchema, UpdateTargetRequestSchema } from '@zitadel/proto/zitadel/action/v2/action_service_pb'; import { InfoSectionType } from '../../info-section/info-section.component'; @Component({ diff --git a/console/src/app/pipes/action-condition-pipe/action-condition-pipe.pipe.ts b/console/src/app/pipes/action-condition-pipe/action-condition-pipe.pipe.ts index 7c4166f60..f007be33f 100644 --- a/console/src/app/pipes/action-condition-pipe/action-condition-pipe.pipe.ts +++ b/console/src/app/pipes/action-condition-pipe/action-condition-pipe.pipe.ts @@ -1,5 +1,5 @@ import { Pipe, PipeTransform } from '@angular/core'; -import { Condition } from '@zitadel/proto/zitadel/action/v2beta/execution_pb'; +import { Condition } from '@zitadel/proto/zitadel/action/v2/execution_pb'; @Pipe({ name: 'condition', diff --git a/console/src/app/services/action.service.ts b/console/src/app/services/action.service.ts index 55dc46f98..5af51a639 100644 --- a/console/src/app/services/action.service.ts +++ b/console/src/app/services/action.service.ts @@ -22,7 +22,7 @@ import { SetExecutionResponse, UpdateTargetRequestSchema, UpdateTargetResponse, -} from '@zitadel/proto/zitadel/action/v2beta/action_service_pb'; +} from '@zitadel/proto/zitadel/action/v2/action_service_pb'; import { UserService } from './user.service'; import { injectQuery } from '@tanstack/angular-query-experimental'; diff --git a/console/src/app/services/grpc.service.ts b/console/src/app/services/grpc.service.ts index 2a2c4bd9c..90cf603ce 100644 --- a/console/src/app/services/grpc.service.ts +++ b/console/src/app/services/grpc.service.ts @@ -27,7 +27,7 @@ import { createGrpcWebTransport } from '@connectrpc/connect-web'; import { createClientFor } from '@zitadel/client'; import { WebKeyService } from '@zitadel/proto/zitadel/webkey/v2beta/webkey_service_pb'; -import { ActionService } from '@zitadel/proto/zitadel/action/v2beta/action_service_pb'; +import { ActionService } from '@zitadel/proto/zitadel/action/v2/action_service_pb'; const createWebKeyServiceClient = createClientFor(WebKeyService); const createActionServiceClient = createClientFor(ActionService); diff --git a/console/src/app/utils/enum.utils.ts b/console/src/app/utils/enum.utils.ts index 15b7d5076..3570633e4 100644 --- a/console/src/app/utils/enum.utils.ts +++ b/console/src/app/utils/enum.utils.ts @@ -30,8 +30,8 @@ type NumericEnum = Record & Record; * // Returns: ['SAML_BINDING_UNSPECIFIED', 'SAML_BINDING_POST', ...] * ``` */ -export function getEnumKeys(enumObject: T): string[] { - return Object.keys(enumObject).filter((key) => isNaN(Number(key))); +export function getEnumKeys(enumObject: T): (keyof T)[] { + return Object.keys(enumObject).filter((key) => isNaN(Number(key))) as unknown as (keyof T)[]; } /** @@ -45,7 +45,7 @@ export function getEnumKeys(enumObject: T): string[] { * // Returns: 'SAML_BINDING_POST' * ``` */ -export function getEnumKeyFromValue(enumObject: T, value: number): string | undefined { +export function getEnumKeyFromValue(enumObject: T, value: number): keyof T | undefined { return Object.keys(enumObject).find((key) => enumObject[key] === value && isNaN(Number(key))); } diff --git a/console/src/assets/i18n/bg.json b/console/src/assets/i18n/bg.json index 8076dc908..3e0399ee5 100644 --- a/console/src/assets/i18n/bg.json +++ b/console/src/assets/i18n/bg.json @@ -622,6 +622,14 @@ "restAsync": "REST асинхронно" }, "TYPES_DESCRIPTION": "Webhook, обаждането обработва кода на състоянието, но отговорът е без значение\nCall, обаждането обработва кода на състоянието и отговора\nAsync, обаждането не обработва нито кода на състоянието, нито отговора, но може да бъде извикано паралелно с други цели", + "PAYLOAD_TYPE": "Тип на съдържанието", + "PAYLOAD_TYPES": { + "UNSPECIFIED": "Не е уточнено", + "JSON": "JSON", + "JWT": "JSON Web Token (JWT)", + "JWE": "Шифрован JSON Web Token (JWE)" + }, + "PAYLOAD_TYPE_DESCRIPTION": "JSON: Стандартно JSON съдържание със заглавна част за подпис и валидация.\nJWT: Съдържанието се изпраща като подписан JWT. Верификацията се извършва чрез подписа на JWT.\nJWE: JWT е допълнително шифрован, за да се предотврати неоторизиран достъп до информацията в съдържанието.", "ENDPOINT": "Крайна точка", "ENDPOINT_DESCRIPTION": "Въведете крайната точка, където се хоства вашият код. Уверете се, че е достъпна за нас!", "TIMEOUT": "Време за изчакване", diff --git a/console/src/assets/i18n/cs.json b/console/src/assets/i18n/cs.json index 7127fd25d..5319bba73 100644 --- a/console/src/assets/i18n/cs.json +++ b/console/src/assets/i18n/cs.json @@ -623,6 +623,14 @@ "restAsync": "REST Asynchronní" }, "TYPES_DESCRIPTION": "Webhook, volání zpracovává stavový kód, ale odpověď je irelevantní\nCall, volání zpracovává stavový kód a odpověď\nAsync, volání nezpracovává ani stavový kód, ani odpověď, ale může být spuštěno paralelně s jinými cíli", + "PAYLOAD_TYPE": "Typ obsahu", + "PAYLOAD_TYPES": { + "UNSPECIFIED": "Neurčeno", + "JSON": "JSON", + "JWT": "JSON Web Token (JWT)", + "JWE": "Šifrovaný JSON Web Token (JWE)" + }, + "PAYLOAD_TYPE_DESCRIPTION": "JSON: Standardní JSON payload s hlavičkou pro podpis a ověření.\nJWT: Payload je odeslán jako podepsané JWT. Ověření probíhá pomocí podpisu JWT.\nJWE: JWT je navíc zašifrován, aby se předešlo neoprávněnému přístupu k informacím v payloadu.", "ENDPOINT": "Koncový bod", "ENDPOINT_DESCRIPTION": "Zadejte koncový bod, kde je hostován váš kód. Ujistěte se, že je pro nás přístupný!", "TIMEOUT": "Časový limit", diff --git a/console/src/assets/i18n/de.json b/console/src/assets/i18n/de.json index cec84a7a8..db89eeb6e 100644 --- a/console/src/assets/i18n/de.json +++ b/console/src/assets/i18n/de.json @@ -623,6 +623,14 @@ "restAsync": "REST Asynchron" }, "TYPES_DESCRIPTION": "Webhook, der Aufruf verarbeitet den Statuscode, aber die Antwort ist irrelevant\nCall, der Aufruf verarbeitet den Statuscode und die Antwort\nAsync, der Aufruf verarbeitet weder Statuscode noch Antwort, kann aber parallel zu anderen Zielen aufgerufen werden", + "PAYLOAD_TYPE": "Payload-Typ", + "PAYLOAD_TYPES": { + "UNSPECIFIED": "Nicht angegeben", + "JSON": "JSON", + "JWT": "JSON Web Token (JWT)", + "JWE": "Verschlüsseltes JSON Web Token (JWE)" + }, + "PAYLOAD_TYPE_DESCRIPTION": "JSON: Standard-JSON-Payload mit einem Signatur-Header zur Verifikation.\nJWT: Der Payload wird als signiertes JWT gesendet. Die Verifikation erfolgt über die JWT-Signatur.\nJWE: Das JWT wird zusätzlich verschlüsselt, um unautorisierten Zugriff auf die Payloadinformationen zu verhindern.", "ENDPOINT": "Endpunkt", "ENDPOINT_DESCRIPTION": "Geben Sie den Endpunkt ein, an dem Ihr Code gehostet wird. Stellen Sie sicher, dass er für uns zugänglich ist!", "TIMEOUT": "Timeout", diff --git a/console/src/assets/i18n/en.json b/console/src/assets/i18n/en.json index e0eb8fd0a..debf125d6 100644 --- a/console/src/assets/i18n/en.json +++ b/console/src/assets/i18n/en.json @@ -624,6 +624,14 @@ "restAsync": "REST Async" }, "TYPES_DESCRIPTION": "Webhook, the call handles the status code but response is irrelevant\nCall, the call handles the status code and response\nAsync, the call handles neither status code nor response, but can be called in parallel with other Targets", + "PAYLOAD_TYPE": "Payload Type", + "PAYLOAD_TYPES": { + "UNSPECIFIED": "Unspecified", + "JSON": "JSON", + "JWT": "JSON Web Token (JWT)", + "JWE": "Encrypted JSON Web Token (JWE)" + }, + "PAYLOAD_TYPE_DESCRIPTION": "JSON: Standard JSON payload with an signature header for verification.\nJWT: Payload is sent as signed JWT. Verification is self-contained using the JWT signature.\nJWE: The JWT is additionally encrypted to prevent unauthorized access to payload information.", "ENDPOINT": "Endpoint", "ENDPOINT_DESCRIPTION": "Enter the endpoint where your code is hosted. Make sure it is accessible to us!", "TIMEOUT": "Timeout", diff --git a/console/src/assets/i18n/es.json b/console/src/assets/i18n/es.json index 55bf3c0fb..60af6987d 100644 --- a/console/src/assets/i18n/es.json +++ b/console/src/assets/i18n/es.json @@ -623,6 +623,14 @@ "restAsync": "REST Asíncrono" }, "TYPES_DESCRIPTION": "Webhook, la llamada maneja el código de estado pero la respuesta es irrelevante\nCall, la llamada maneja el código de estado y la respuesta\nAsync, la llamada no maneja ni el código de estado ni la respuesta, pero puede ser llamada en paralelo con otros objetivos", + "PAYLOAD_TYPE": "Tipo de carga útil", + "PAYLOAD_TYPES": { + "UNSPECIFIED": "No especificado", + "JSON": "JSON", + "JWT": "JSON Web Token (JWT)", + "JWE": "JSON Web Token cifrado (JWE)" + }, + "PAYLOAD_TYPE_DESCRIPTION": "JSON: Carga útil JSON estándar con un encabezado de firma para verificación.\nJWT: La carga útil se envía como un JWT firmado. La verificación es autónoma mediante la firma del JWT.\nJWE: El JWT se cifra adicionalmente para evitar el acceso no autorizado a la información de la carga útil.", "ENDPOINT": "Punto de conexión", "ENDPOINT_DESCRIPTION": "Introduce el punto de conexión donde se aloja tu código. ¡Asegúrate de que sea accesible para nosotros!", "TIMEOUT": "Tiempo de espera", diff --git a/console/src/assets/i18n/fr.json b/console/src/assets/i18n/fr.json index 536113b5f..6a7ede243 100644 --- a/console/src/assets/i18n/fr.json +++ b/console/src/assets/i18n/fr.json @@ -623,6 +623,14 @@ "restAsync": "REST Asynchrone" }, "TYPES_DESCRIPTION": "Webhook, l'appel gère le code d'état mais la réponse est sans importance\nCall, l'appel gère le code d'état et la réponse\nAsync, l'appel ne gère ni le code d'état ni la réponse, mais peut être appelé en parallèle avec d'autres cibles", + "PAYLOAD_TYPE": "Type de charge utile", + "PAYLOAD_TYPES": { + "UNSPECIFIED": "Non spécifié", + "JSON": "JSON", + "JWT": "JSON Web Token (JWT)", + "JWE": "JSON Web Token chiffré (JWE)" + }, + "PAYLOAD_TYPE_DESCRIPTION": "JSON: Charge utile JSON standard avec un en‑tête de signature pour la vérification.\nJWT: La charge utile est envoyée sous forme de JWT signé. La vérification se fait via la signature du JWT.\nJWE: Le JWT est en plus chiffré pour empêcher l'accès non autorisé aux informations de la charge utile.", "ENDPOINT": "Point de terminaison", "ENDPOINT_DESCRIPTION": "Entrez le point de terminaison où votre code est hébergé. Assurez-vous qu'il nous est accessible !", "TIMEOUT": "Délai d'attente", diff --git a/console/src/assets/i18n/hu.json b/console/src/assets/i18n/hu.json index fccc7853b..0bb2954d9 100644 --- a/console/src/assets/i18n/hu.json +++ b/console/src/assets/i18n/hu.json @@ -623,6 +623,14 @@ "restAsync": "REST Aszinkron" }, "TYPES_DESCRIPTION": "Webhook, a hívás kezeli az állapotkódot, de a válasz lényegtelen\nCall, a hívás kezeli az állapotkódot és a választ\nAsync, a hívás sem az állapotkódot, sem a választ nem kezeli, de párhuzamosan hívható más célokkal", + "PAYLOAD_TYPE": "Tartalom típusa", + "PAYLOAD_TYPES": { + "UNSPECIFIED": "Nincs megadva", + "JSON": "JSON", + "JWT": "JSON Web Token (JWT)", + "JWE": "Titkosított JSON Web Token (JWE)" + }, + "PAYLOAD_TYPE_DESCRIPTION": "JSON: Szabványos JSON üzenet aláírási fejlécvel az ellenőrzéshez.\nJWT: Az üzenet aláírt JWT-ként kerül elküldésre. Az ellenőrzés a JWT aláírásával történik.\nJWE: A JWT további titkosítást kap, hogy megakadályozza az illetéktelen hozzáférést az üzenet adataihoz.", "ENDPOINT": "Végpont", "ENDPOINT_DESCRIPTION": "Adja meg azt a végpontot, ahol a kódja található. Győződjön meg arról, hogy elérhető számunkra!", "TIMEOUT": "Időtúllépés", diff --git a/console/src/assets/i18n/id.json b/console/src/assets/i18n/id.json index 30c2ca21e..a64c510a5 100644 --- a/console/src/assets/i18n/id.json +++ b/console/src/assets/i18n/id.json @@ -623,6 +623,14 @@ "restAsync": "REST Asinkron" }, "TYPES_DESCRIPTION": "Webhook, panggilan menangani kode status tetapi respons tidak relevan\nCall, panggilan menangani kode status dan respons\nAsync, panggilan tidak menangani kode status maupun respons, tetapi dapat dipanggil secara paralel dengan Target lain", + "PAYLOAD_TYPE": "Tipe payload", + "PAYLOAD_TYPES": { + "UNSPECIFIED": "Tidak ditentukan", + "JSON": "JSON", + "JWT": "JSON Web Token (JWT)", + "JWE": "JSON Web Token terenkripsi (JWE)" + }, + "PAYLOAD_TYPE_DESCRIPTION": "JSON: Payload JSON standar dengan header tanda tangan untuk verifikasi.\nJWT: Payload dikirim sebagai JWT yang ditandatangani. Verifikasi dilakukan melalui tanda tangan JWT.\nJWE: JWT dienkripsi tambahan untuk mencegah akses tidak sah ke informasi payload.", "ENDPOINT": "Titik Akhir", "ENDPOINT_DESCRIPTION": "Masukkan titik akhir tempat kode Anda dihosting. Pastikan dapat diakses oleh kami!", "TIMEOUT": "Batas Waktu", diff --git a/console/src/assets/i18n/it.json b/console/src/assets/i18n/it.json index 6475c2db3..9cc110e40 100644 --- a/console/src/assets/i18n/it.json +++ b/console/src/assets/i18n/it.json @@ -622,6 +622,14 @@ "restAsync": "REST Asincrono" }, "TYPES_DESCRIPTION": "Webhook, la chiamata gestisce il codice di stato ma la risposta è irrilevante\nCall, la chiamata gestisce il codice di stato e la risposta\nAsync, la chiamata non gestisce né il codice di stato né la risposta, ma può essere eseguita in parallelo con altri obiettivi", + "PAYLOAD_TYPE": "Tipo di payload", + "PAYLOAD_TYPES": { + "UNSPECIFIED": "Non specificato", + "JSON": "JSON", + "JWT": "JSON Web Token (JWT)", + "JWE": "JSON Web Token crittografato (JWE)" + }, + "PAYLOAD_TYPE_DESCRIPTION": "JSON: Payload JSON standard con un'intestazione di firma per la verifica.\nJWT: Il payload viene inviato come JWT firmato. La verifica è autonoma utilizzando la firma del JWT.\nJWE: Il JWT è inoltre crittografato per prevenire l'accesso non autorizzato alle informazioni del payload.", "ENDPOINT": "Endpoint", "ENDPOINT_DESCRIPTION": "Inserisci l'endpoint in cui è ospitato il tuo codice. Assicurati che sia accessibile per noi!", "TIMEOUT": "Timeout", diff --git a/console/src/assets/i18n/ja.json b/console/src/assets/i18n/ja.json index d05620cde..db157d208 100644 --- a/console/src/assets/i18n/ja.json +++ b/console/src/assets/i18n/ja.json @@ -623,6 +623,14 @@ "restAsync": "REST 非同期" }, "TYPES_DESCRIPTION": "Webhook、呼び出しはステータスコードを処理しますが、応答は無関係です\nCall、呼び出しはステータスコードと応答を処理します\nAsync、呼び出しはステータスコードも応答も処理しませんが、他のターゲットと並行して呼び出すことができます", + "PAYLOAD_TYPE": "ペイロードの種類", + "PAYLOAD_TYPES": { + "UNSPECIFIED": "未指定", + "JSON": "JSON", + "JWT": "JSON Web Token (JWT)", + "JWE": "暗号化された JSON Web Token (JWE)" + }, + "PAYLOAD_TYPE_DESCRIPTION": "JSON: 検証用の署名ヘッダーを持つ標準の JSON ペイロード。\nJWT: ペイロードは署名付き JWT として送信されます。検証は JWT の署名で行われます。\nJWE: JWT はさらに暗号化され、ペイロード情報への不正アクセスを防ぎます。", "ENDPOINT": "エンドポイント", "ENDPOINT_DESCRIPTION": "コードがホストされているエンドポイントを入力します。アクセス可能であることを確認してください。", "TIMEOUT": "タイムアウト", diff --git a/console/src/assets/i18n/ko.json b/console/src/assets/i18n/ko.json index f70977e4d..33d977098 100644 --- a/console/src/assets/i18n/ko.json +++ b/console/src/assets/i18n/ko.json @@ -623,6 +623,14 @@ "restAsync": "REST 비동기" }, "TYPES_DESCRIPTION": "Webhook, 호출은 상태 코드를 처리하지만 응답은 중요하지 않습니다\nCall, 호출은 상태 코드와 응답을 처리합니다\nAsync, 호출은 상태 코드나 응답을 처리하지 않지만 다른 대상과 병렬로 호출할 수 있습니다", + "PAYLOAD_TYPE": "페이로드 유형", + "PAYLOAD_TYPES": { + "UNSPECIFIED": "지정되지 않음", + "JSON": "JSON", + "JWT": "JSON Web Token (JWT)", + "JWE": "암호화된 JSON Web Token (JWE)" + }, + "PAYLOAD_TYPE_DESCRIPTION": "JSON: 검증을 위한 서명 헤더가 포함된 표준 JSON 페이로드.\nJWT: 페이로드는 서명된 JWT로 전송됩니다. 검증은 JWT 서명을 통해 수행됩니다.\nJWE: JWT는 추가로 암호화되어 페이로드 정보에 대한 무단 접근을 방지합니다.", "ENDPOINT": "엔드포인트", "ENDPOINT_DESCRIPTION": "코드가 호스팅되는 엔드포인트를 입력하십시오. 우리에게 액세스할 수 있는지 확인하십시오!", "TIMEOUT": "시간 초과", diff --git a/console/src/assets/i18n/mk.json b/console/src/assets/i18n/mk.json index adeb8293b..347544858 100644 --- a/console/src/assets/i18n/mk.json +++ b/console/src/assets/i18n/mk.json @@ -623,6 +623,14 @@ "restAsync": "REST Асинхроно" }, "TYPES_DESCRIPTION": "Webhook, повикот го обработува статусниот код но одговорот е ирелевантен\nCall, повикот го обработува статусниот код и одговорот\nAsync, повикот не го обработува ниту статусниот код ниту одговорот, но може да се повика паралелно со други цели", + "PAYLOAD_TYPE": "Тип на содржина", + "PAYLOAD_TYPES": { + "UNSPECIFIED": "Не е наведено", + "JSON": "JSON", + "JWT": "JSON Web Token (JWT)", + "JWE": "Шифриран JSON Web Token (JWE)" + }, + "PAYLOAD_TYPE_DESCRIPTION": "JSON: Стандардна JSON содржина со заглавие за потпис и верификација.\nJWT: Содржината се праќа како потпишан JWT. Верификацијата се врши преку потписот на JWT.\nJWE: JWT-от е дополнително шифриран за да се спречи неовластен пристап до информациите во содржината.", "ENDPOINT": "Крајна точка", "ENDPOINT_DESCRIPTION": "Внесете ја крајната точка каде што е хостиран вашиот код. Осигурете се дека е достапна за нас!", "TIMEOUT": "Време на истекување", diff --git a/console/src/assets/i18n/nl.json b/console/src/assets/i18n/nl.json index ce019105b..212c6fcb3 100644 --- a/console/src/assets/i18n/nl.json +++ b/console/src/assets/i18n/nl.json @@ -623,6 +623,14 @@ "restAsync": "REST Asynchroon" }, "TYPES_DESCRIPTION": "Webhook, de oproep verwerkt de statuscode maar de reactie is irrelevant\nCall, de oproep verwerkt de statuscode en de reactie\nAsync, de oproep verwerkt noch de statuscode noch de reactie, maar kan parallel aan andere doelen worden aangeroepen", + "PAYLOAD_TYPE": "Payload-type", + "PAYLOAD_TYPES": { + "UNSPECIFIED": "Niet opgegeven", + "JSON": "JSON", + "JWT": "JSON Web Token (JWT)", + "JWE": "Versleuteld JSON Web Token (JWE)" + }, + "PAYLOAD_TYPE_DESCRIPTION": "JSON: Standaard JSON-payload met een handtekening-header voor verificatie.\nJWT: De payload wordt als een ondertekend JWT verzonden. Verificatie gebeurt via de JWT-handtekening.\nJWE: Het JWT wordt daarnaast versleuteld om ongeautoriseerde toegang tot payloadinformatie te voorkomen.", "ENDPOINT": "Eindpunt", "ENDPOINT_DESCRIPTION": "Voer het eindpunt in waar uw code wordt gehost. Zorg ervoor dat het voor ons toegankelijk is!", "TIMEOUT": "Time-out", diff --git a/console/src/assets/i18n/pl.json b/console/src/assets/i18n/pl.json index 39980e25d..066f62664 100644 --- a/console/src/assets/i18n/pl.json +++ b/console/src/assets/i18n/pl.json @@ -622,6 +622,14 @@ "restAsync": "REST Asynchroniczny" }, "TYPES_DESCRIPTION": "Webhook, wywołanie obsługuje kod stanu, ale odpowiedź jest nieistotna\nCall, wywołanie obsługuje kod stanu i odpowiedź\nAsync, wywołanie nie obsługuje ani kodu stanu, ani odpowiedzi, ale może być wywoływane równolegle z innymi celami", + "PAYLOAD_TYPE": "Typ ładunku", + "PAYLOAD_TYPES": { + "UNSPECIFIED": "Nieokreślony", + "JSON": "JSON", + "JWT": "JSON Web Token (JWT)", + "JWE": "Zaszyfrowany JSON Web Token (JWE)" + }, + "PAYLOAD_TYPE_DESCRIPTION": "JSON: Standardowy ładunek JSON z nagłówkiem podpisu do weryfikacji.\nJWT: Ładunek jest wysyłany jako podpisany JWT. Weryfikacja odbywa się za pomocą podpisu JWT.\nJWE: JWT jest dodatkowo szyfrowany, aby zapobiec nieautoryzowanemu dostępowi do informacji w ładunku.", "ENDPOINT": "Punkt końcowy", "ENDPOINT_DESCRIPTION": "Wprowadź punkt końcowy, w którym hostowany jest Twój kod. Upewnij się, że jest dla nas dostępny!", "TIMEOUT": "Limit czasu", diff --git a/console/src/assets/i18n/pt.json b/console/src/assets/i18n/pt.json index 62a667c3b..09d79256d 100644 --- a/console/src/assets/i18n/pt.json +++ b/console/src/assets/i18n/pt.json @@ -623,6 +623,14 @@ "restAsync": "REST Assíncrono" }, "TYPES_DESCRIPTION": "Webhook, a chamada lida com o código de status, mas a resposta é irrelevante\nCall, a chamada lida com o código de status e a resposta\nAsync, a chamada não lida nem com o código de status nem com a resposta, mas pode ser chamada em paralelo com outros alvos", + "PAYLOAD_TYPE": "Tipo de carga útil", + "PAYLOAD_TYPES": { + "UNSPECIFIED": "Não especificado", + "JSON": "JSON", + "JWT": "JSON Web Token (JWT)", + "JWE": "JSON Web Token cifrado (JWE)" + }, + "PAYLOAD_TYPE_DESCRIPTION": "JSON: Payload JSON padrão com um cabeçalho de assinatura para verificação.\nJWT: O payload é enviado como um JWT assinado. A verificação é feita pela assinatura do JWT.\nJWE: O JWT é adicionalmente cifrado para evitar acesso não autorizado às informações do payload.", "ENDPOINT": "Ponto de Extremidade", "ENDPOINT_DESCRIPTION": "Insira o ponto de extremidade onde seu código está hospedado. Certifique-se de que ele esteja acessível para nós!", "TIMEOUT": "Tempo Limite", diff --git a/console/src/assets/i18n/ro.json b/console/src/assets/i18n/ro.json index dc82c4774..7cebb7c3e 100644 --- a/console/src/assets/i18n/ro.json +++ b/console/src/assets/i18n/ro.json @@ -623,6 +623,14 @@ "restAsync": "REST Asincron" }, "TYPES_DESCRIPTION": "Webhook, apelul gestionează codul de stare, dar răspunsul este irelevant\nCall, apelul gestionează codul de stare și răspunsul\nAsync, apelul nu gestionează nici codul de stare, nici răspunsul, dar poate fi apelat în paralel cu alte Ținte", + "PAYLOAD_TYPE": "Tipul payload-ului", + "PAYLOAD_TYPES": { + "UNSPECIFIED": "Nespecificat", + "JSON": "JSON", + "JWT": "JSON Web Token (JWT)", + "JWE": "JSON Web Token criptat (JWE)" + }, + "PAYLOAD_TYPE_DESCRIPTION": "JSON: Payload JSON standard cu un antet de semnătură pentru verificare.\nJWT: Payload-ul este trimis ca un JWT semnat. Verificarea se realizează prin semnătura JWT.\nJWE: JWT-ul este în plus criptat pentru a preveni accesul neautorizat la informațiile din payload.", "ENDPOINT": "Punct Final", "ENDPOINT_DESCRIPTION": "Introduceți punctul final unde este găzduit codul dvs. Asigurați-vă că este accesibil pentru noi!", "TIMEOUT": "Timeout", diff --git a/console/src/assets/i18n/ru.json b/console/src/assets/i18n/ru.json index 4d22ec1db..1c20c315e 100644 --- a/console/src/assets/i18n/ru.json +++ b/console/src/assets/i18n/ru.json @@ -622,6 +622,14 @@ "restAsync": "REST Асинхронный" }, "TYPES_DESCRIPTION": "Webhook, вызов обрабатывает код состояния, но ответ не имеет значения\nCall, вызов обрабатывает код состояния и ответ\nAsync, вызов не обрабатывает ни код состояния, ни ответ, но может выполняться параллельно с другими целями", + "PAYLOAD_TYPE": "Тип полезной нагрузки", + "PAYLOAD_TYPES": { + "UNSPECIFIED": "Не указано", + "JSON": "JSON", + "JWT": "JSON Web Token (JWT)", + "JWE": "Зашифрованный JSON Web Token (JWE)" + }, + "PAYLOAD_TYPE_DESCRIPTION": "JSON: Стандартная JSON‑полезная нагрузка с заголовком подписи для проверки.\nJWT: Полезная нагрузка отправляется как подписанный JWT. Проверка выполняется с помощью подписи JWT.\nJWE: JWT дополнительно шифруется, чтобы предотвратить несанкционированный доступ к информации в полезной нагрузке.", "ENDPOINT": "Конечная точка", "ENDPOINT_DESCRIPTION": "Введите конечную точку, где размещен ваш код. Убедитесь, что он доступен для нас!", "TIMEOUT": "Тайм-аут", diff --git a/console/src/assets/i18n/sv.json b/console/src/assets/i18n/sv.json index 45319e865..d4afd10bf 100644 --- a/console/src/assets/i18n/sv.json +++ b/console/src/assets/i18n/sv.json @@ -623,6 +623,14 @@ "restAsync": "REST Asynkron" }, "TYPES_DESCRIPTION": "Webhook, anropet hanterar statuskoden men svaret är irrelevant\nCall, anropet hanterar statuskoden och svaret\nAsync, anropet hanterar varken statuskod eller svar men kan anropas parallellt med andra mål", + "PAYLOAD_TYPE": "Payload-typ", + "PAYLOAD_TYPES": { + "UNSPECIFIED": "Ej angiven", + "JSON": "JSON", + "JWT": "JSON Web Token (JWT)", + "JWE": "Krypterad JSON Web Token (JWE)" + }, + "PAYLOAD_TYPE_DESCRIPTION": "JSON: Standard JSON-payload med en signatur-header för verifiering.\nJWT: Payload skickas som en signerad JWT. Verifiering sker via JWT-signaturen.\nJWE: JWT är dessutom krypterad för att förhindra obehörig åtkomst till payload-information.", "ENDPOINT": "Slutpunkt", "ENDPOINT_DESCRIPTION": "Ange slutpunkten där din kod finns. Se till att den är tillgänglig för oss!", "TIMEOUT": "Tidsgräns", diff --git a/console/src/assets/i18n/tr.json b/console/src/assets/i18n/tr.json index 2a3a4c147..d2cb7d12a 100644 --- a/console/src/assets/i18n/tr.json +++ b/console/src/assets/i18n/tr.json @@ -623,6 +623,14 @@ "restAsync": "REST Async" }, "TYPES_DESCRIPTION": "Webhook, çağrı durum kodunu işler ancak yanıt önemsizdir\nCall, çağrı durum kodunu ve yanıtını işler\nAsync, çağrı ne durum kodunu ne de yanıtını işler, ancak diğer Hedeflerle paralel olarak çağrılabilir", + "PAYLOAD_TYPE": "Yük türü", + "PAYLOAD_TYPES": { + "UNSPECIFIED": "Belirtilmemiş", + "JSON": "JSON", + "JWT": "JSON Web Token (JWT)", + "JWE": "Şifrelenmiş JSON Web Token (JWE)" + }, + "PAYLOAD_TYPE_DESCRIPTION": "JSON: Doğrulama için imza başlığı olan standart JSON payload.\nJWT: Payload imzalı bir JWT olarak gönderilir. Doğrulama, JWT imzası ile gerçekleştirilir.\nJWE: JWT, payload bilgilerine yetkisiz erişimi önlemek için ayrıca şifrelenir.", "ENDPOINT": "Endpoint", "ENDPOINT_DESCRIPTION": "Kodunuzun barındırıldığı endpoint'i girin. Bize erişilebilir olduğundan emin olun!", "TIMEOUT": "Zaman Aşımı", diff --git a/console/src/assets/i18n/uk.json b/console/src/assets/i18n/uk.json index e6dc6c7ed..1cf335c1f 100644 --- a/console/src/assets/i18n/uk.json +++ b/console/src/assets/i18n/uk.json @@ -624,6 +624,14 @@ "restAsync": "REST Асинхронний" }, "TYPES_DESCRIPTION": "Webhook, виклик обробляє код стану, але відповідь не має значення\nВиклик, виклик обробляє код стану та відповідь\nАсинхронний, виклик не обробляє ні код стану, ні відповідь, але може викликатися паралельно з іншими цілями", + "PAYLOAD_TYPE": "Тип корисного навантаження", + "PAYLOAD_TYPES": { + "UNSPECIFIED": "Не вказано", + "JSON": "JSON", + "JWT": "JSON Web Token (JWT)", + "JWE": "Зашифрований JSON Web Token (JWE)" + }, + "PAYLOAD_TYPE_DESCRIPTION": "JSON: Стандартний JSON payload з заголовком підпису для перевірки.\nJWT: Payload надсилається як підписаний JWT. Перевірка відбувається за допомогою підпису JWT.\nJWE: JWT додатково шифрується, щоб запобігти несанкціонованому доступу до інформації у payload.", "ENDPOINT": "Кінцева точка", "ENDPOINT_DESCRIPTION": "Введіть кінцеву точку, де розміщений ваш код. Переконайтеся, що вона доступна для нас!", "TIMEOUT": "Тайм-аут", diff --git a/console/src/assets/i18n/zh.json b/console/src/assets/i18n/zh.json index c439e0260..f61ce3873 100644 --- a/console/src/assets/i18n/zh.json +++ b/console/src/assets/i18n/zh.json @@ -623,6 +623,14 @@ "restAsync": "REST 异步" }, "TYPES_DESCRIPTION": "Webhook,调用处理状态码但响应无关紧要\nCall,调用处理状态码和响应\nAsync,调用既不处理状态码也不处理响应,但可以与其他目标并行调用", + "PAYLOAD_TYPE": "有效负载类型", + "PAYLOAD_TYPES": { + "UNSPECIFIED": "未指定", + "JSON": "JSON", + "JWT": "JSON Web Token (JWT)", + "JWE": "加密的 JSON Web Token (JWE)" + }, + "PAYLOAD_TYPE_DESCRIPTION": "JSON: 用于验证的签名头的标准 JSON 有效负载。\nJWT: 有效负载作为已签名的 JWT 发送。验证通过 JWT 签名完成。\nJWE: JWT 另外被加密,以防止对有效负载信息的未授权访问。", "ENDPOINT": "端点", "ENDPOINT_DESCRIPTION": "输入您的代码托管的端点。确保我们可以访问它!", "TIMEOUT": "超时", diff --git a/docs/docs/guides/integrate/actions/go/json b/docs/docs/guides/integrate/actions/go/json new file mode 100644 index 000000000..b398c27fb --- /dev/null +++ b/docs/docs/guides/integrate/actions/go/json @@ -0,0 +1,39 @@ +package main + +import ( + "fmt" + "io" + "net/http" + + "github.com/zitadel/zitadel-go/v3/pkg/actions" +) + +const signingKey = "somekey" // signing key received after creating the target + +// webhook HandleFunc to read the request body and then print out the contents +func webhook(w http.ResponseWriter, req *http.Request) { + // read the body content + sentBody, err := io.ReadAll(req.Body) + if err != nil { + // if there was an error while reading the body return an error + http.Error(w, "error", http.StatusInternalServerError) + return + } + defer req.Body.Close() + // validate signature + if err := actions.ValidateRequestPayload(sentBody, &req.Header, signingKey); err != nil { + // if the signed content is not equal the sent content return an error + http.Error(w, "error", http.StatusInternalServerError) + return + } + // print out the read content + fmt.Println(string(sentBody)) +} + +func main() { + // handle the HTTP call under "/webhook" + http.HandleFunc("/webhook", webhook) + + // start an HTTP server with the before defined function to handle the endpoint under "http://localhost:8090" + http.ListenAndServe(":8090", nil) +} diff --git a/docs/docs/guides/integrate/actions/go/jwe b/docs/docs/guides/integrate/actions/go/jwe new file mode 100644 index 000000000..1f69fa469 --- /dev/null +++ b/docs/docs/guides/integrate/actions/go/jwe @@ -0,0 +1,107 @@ +package main + +import ( + "context" + "crypto/x509" + "encoding/pem" + "fmt" + "io" + "log" + "net/http" + "os" + + "github.com/go-jose/go-jose/v4" + "github.com/zitadel/oidc/v3/pkg/client" + "github.com/zitadel/oidc/v3/pkg/client/rp" + "github.com/zitadel/oidc/v3/pkg/oidc" +) + +// webhook HandleFunc to read the request body and then print out the contents +func webhook(signatureAlgorithms []jose.SignatureAlgorithm, keySet oidc.KeySet, privateKey any) func(w http.ResponseWriter, req *http.Request) { + return func(w http.ResponseWriter, req *http.Request) { + // read the body content + sentBody, err := io.ReadAll(req.Body) + if err != nil { + // if there was an error while reading the body return an error + http.Error(w, "error", http.StatusInternalServerError) + return + } + defer req.Body.Close() + // decrypt the JWE, validate the JWT and extract the payload from it + payload, err := validateJWE(req.Context(), string(sentBody), signatureAlgorithms, keySet, privateKey) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + // print out the payload + fmt.Println(string(payload)) + } +} + +func validateJWE(ctx context.Context, jweString string, signatureAlgorithms []jose.SignatureAlgorithm, keySet oidc.KeySet, privateKey any) ([]byte, error) { + // For parsing the JWE, we need to specify which algorithms we expect. + // You can specify either `RSA_OAEP_256` (rsa.PrivateKey) or `ECDH_ES_A256KW` (ecdsa.PrivateKey) or both, depending on which private key(s) you have. + // The content encryption algorithm will always be `A256GCM` as used by Zitadel. + parsedJWE, err := jose.ParseEncrypted(jweString, []jose.KeyAlgorithm{jose.RSA_OAEP_256, jose.ECDH_ES_A256KW}, []jose.ContentEncryption{jose.A256GCM}) + if err != nil { + return nil, err + } + + // In this example we only use a single key and loaded it on start, but you can manage and rotate the key used for the encryption through the API. + // You might also have a key management system in your application. + // Using the KeyID from the JWE header (parsedJWE.Header.KeyID), we could load the correct private key for decryption. + + // Decrypt the JWE using the private key to get the inner JWT + decryptedJWT, err := parsedJWE.Decrypt(privateKey) + if err != nil { + return nil, err + } + // Now validate the inner JWT and return the payload + return validateJWT(ctx, string(decryptedJWT), signatureAlgorithms, keySet) +} + +func validateJWT(ctx context.Context, jwtString string, algorithms []jose.SignatureAlgorithm, keySet oidc.KeySet) ([]byte, error) { + // Parse the signed JWT to get the JWS object (which contains the signatures and unverified payload) + parsedJWS, err := jose.ParseSigned(jwtString, algorithms) + if err != nil { + return nil, err + } + + // Verify the signature using the retrieved key and return the payload + return keySet.VerifySignature(ctx, parsedJWS) +} + +func main() { + ctx := context.Background() + // set the issuer to the ZITADEL instance URL + issuer := "http://localhost:8080" + + // the oidc client library will call the discovery endpoint to get the JWKS URI and supported signing algorithms + discover, err := client.Discover(ctx, issuer, http.DefaultClient) + if err != nil { + log.Fatal(err) + } + signatureAlgorithms := make([]jose.SignatureAlgorithm, len(discover.IDTokenSigningAlgValuesSupported)) + for i, alg := range discover.IDTokenSigningAlgValuesSupported { + signatureAlgorithms[i] = jose.SignatureAlgorithm(alg) + } + keySet := rp.NewRemoteKeySet(http.DefaultClient, discover.JwksURI) + + // this example uses the private key from the PEM file to decrypt the JWE + // make sure to load your private key accordingly + privateKeyPEM, err := os.ReadFile("./private-key.pem") + if err != nil { + log.Fatal(err) + } + block, _ := pem.Decode(privateKeyPEM) + privateKey, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + log.Fatal(err) + } + + // handle the HTTP call under "/webhook" + http.HandleFunc("/webhook", webhook(signatureAlgorithms, keySet, privateKey)) + + // start an HTTP server with the before defined function to handle the endpoint under "http://localhost:8090" + http.ListenAndServe(":8090", nil) +} diff --git a/docs/docs/guides/integrate/actions/go/jwt b/docs/docs/guides/integrate/actions/go/jwt new file mode 100644 index 000000000..db31fd14b --- /dev/null +++ b/docs/docs/guides/integrate/actions/go/jwt @@ -0,0 +1,70 @@ +package main + +import ( + "context" + "fmt" + "io" + "log" + "net/http" + + "github.com/go-jose/go-jose/v4" + "github.com/zitadel/oidc/v3/pkg/client" + "github.com/zitadel/oidc/v3/pkg/client/rp" + "github.com/zitadel/oidc/v3/pkg/oidc" +) + +// webhook HandleFunc to read the request body and then print out the contents +func webhook(signatureAlgorithms []jose.SignatureAlgorithm, keySet oidc.KeySet) func(w http.ResponseWriter, req *http.Request) { + return func(w http.ResponseWriter, req *http.Request) { + // read the body content + sentBody, err := io.ReadAll(req.Body) + if err != nil { + // if there was an error while reading the body return an error + http.Error(w, "error", http.StatusInternalServerError) + return + } + defer req.Body.Close() + // validate the JWT and extract the payload from it + payload, err := validateJWT(req.Context(), string(sentBody), signatureAlgorithms, keySet) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + // print out the payload + fmt.Println(string(payload)) + } +} + +func validateJWT(ctx context.Context, jwtString string, algorithms []jose.SignatureAlgorithm, keySet oidc.KeySet) ([]byte, error) { + // Parse the signed JWT to get the JWS object (which contains the signatures and unverified payload) + parsedJWS, err := jose.ParseSigned(jwtString, algorithms) + if err != nil { + return nil, err + } + + // Verify the signature using the retrieved key and return the payload + return keySet.VerifySignature(ctx, parsedJWS) +} + +func main() { + ctx := context.Background() + // set the issuer to the ZITADEL instance URL + issuer := "http://localhost:8080" + + // the oidc client library will call the discovery endpoint to get the JWKS URI and supported signing algorithms + discover, err := client.Discover(ctx, issuer, http.DefaultClient) + if err != nil { + log.Fatal(err) + } + signatureAlgorithms := make([]jose.SignatureAlgorithm, len(discover.IDTokenSigningAlgValuesSupported)) + for i, alg := range discover.IDTokenSigningAlgValuesSupported { + signatureAlgorithms[i] = jose.SignatureAlgorithm(alg) + } + keySet := rp.NewRemoteKeySet(http.DefaultClient, discover.JwksURI) + + // handle the HTTP call under "/webhook" + http.HandleFunc("/webhook", webhook(signatureAlgorithms, keySet)) + + // start an HTTP server with the before defined function to handle the endpoint under "http://localhost:8090" + http.ListenAndServe(":8090", nil) +} diff --git a/docs/docs/guides/integrate/actions/testing-request-signature.md b/docs/docs/guides/integrate/actions/testing-request-signature.md deleted file mode 100644 index fe9913b76..000000000 --- a/docs/docs/guides/integrate/actions/testing-request-signature.md +++ /dev/null @@ -1,224 +0,0 @@ ---- -title: Test Actions Request Signature Check ---- - -This guide shows you how to leverage the ZITADEL actions feature to react to API requests in your ZITADEL instance. -You can use the actions feature to create a target that will be called when a specific API request occurs. -This is useful for information provisioning in between systems or for triggering workflows based on API requests in ZITADEL. - -## Prerequisites - -Before you start, make sure you have everything set up correctly. - -- You need to be at least a ZITADEL [_IAM_OWNER_](/guides/manage/console/managers) -- Your ZITADEL instance needs to have the actions feature enabled. - -:::info -Note that this guide assumes that ZITADEL is running on the same machine as the target and can be reached via `localhost`. -In case you are using a different setup, you need to adjust the target URL accordingly and will need to make sure that the target is reachable from ZITADEL. -::: - -:::warning -To marshal and unmarshal the request please use a package like [protojson](https://pkg.go.dev/google.golang.org/protobuf/encoding/protojson), -as the request is a protocol buffer message, to avoid potential problems with the attribute names. -::: - -## Start example target - -To test the actions feature, you need to create a target that will be called when an API endpoint is called. -You will need to implement a listener that can receive HTTP requests, check the signature and process the request. -For this example, we will use a simple Go HTTP server that will print the received request to standard output. -The 'signingKey' is the key received in the next step 'Create target'. - -```go -package main - -import ( - "fmt" - "io" - "net/http" - - "github.com/zitadel/zitadel-go/v3/pkg/actions" -) - -const signingKey = "somekey" // signing key received after creating the target - -// webhook HandleFunc to read the request body and then print out the contents -func webhook(w http.ResponseWriter, req *http.Request) { - // read the body content - sentBody, err := io.ReadAll(req.Body) - if err != nil { - // if there was an error while reading the body return an error - http.Error(w, "error", http.StatusInternalServerError) - return - } - defer req.Body.Close() - // validate signature - if err := actions.ValidateRequestPayload(sentBody, &req.Header, signingKey); err != nil { - // if the signed content is not equal the sent content return an error - http.Error(w, "error", http.StatusInternalServerError) - return - } - // print out the read content - fmt.Println(string(sentBody)) -} - -func main() { - // handle the HTTP call under "/webhook" - http.HandleFunc("/webhook", webhook) - - // start an HTTP server with the before defined function to handle the endpoint under "http://localhost:8090" - http.ListenAndServe(":8090", nil) -} -``` - -:::info -The example above runs only on your local machine (`localhost`). -To test it with Zitadel, you must make your listener reachable from the internet. -You can do this by using **Webhook.site** (see [Creating a Listener with Webhook.site](./webhook-site-setup)). -::: - -## Create target - -As you see in the example above the target is created with HTTP and port '8090' and if we want to use it as webhook, the target can be created as follows: - -See [Create a target](/apis/resources/action_service_v2/action-service-create-target) for more detailed information. - -```shell -curl -L -X POST 'https://$CUSTOM-DOMAIN/v2/actions/targets' \ --H 'Content-Type: application/json' \ --H 'Accept: application/json' \ --H 'Authorization: Bearer ' \ ---data-raw '{ - "name": "local webhook", - "restWebhook": { - "interruptOnError": true - }, - "endpoint": "http://localhost:8090/webhook", - "timeout": "10s" -}' -``` - -Example response after creating the target: -```json -{ - "id": "344649040681500814", - "creationDate": "2025-10-31T15:00:36.432595dZ", - "signingKey": "somekey" -} -``` -Save the returned ID to set in the execution. Use the `signingKey` to validate the request signature. - -## Set execution - -To configure ZITADEL to call the target when an API endpoint is called, you need to set an execution and define the request -condition. - -See [Set an execution](/apis/resources/action_service_v2/action-service-set-execution) for more detailed information. - -```shell -curl -L -X PUT 'https://$CUSTOM-DOMAIN/v2/actions/executions' \ --H 'Content-Type: application/json' \ --H 'Accept: application/json' \ --H 'Authorization: Bearer ' \ ---data-raw '{ - "condition": { - "request": { - "method": "/zitadel.user.v2.UserService/CreateUser" - } - }, - "targets": [ - "" - ] -}' -``` - -## Example call - -Now that you have set up the target and execution, you can test it by creating a user through the Console UI or -by calling the ZITADEL API to create a human user. - -```shell -curl -L -X POST 'https://$CUSTOM-DOMAIN/v2/users/new' \ --H 'Content-Type: application/json' \ --H 'Accept: application/json' \ --H 'Authorization: Bearer ' \ ---data-raw '{ - "organizationId": "344648897353810062", - "human": - { - "profile": - { - "givenName": "Minnie", - "familyName": "Mouse", - "nickName": "Mini", - "displayName": "Minnie Mouse", - "preferredLanguage": "en", - "gender": "GENDER_FEMALE" - }, - "email": - { - "email": "mini+test@mouse.com" - } - } -}' -``` - -Your server should now print out something like the following. Check out -the [Sent information Request](./usage#sent-information-request) payload description. - -```json -{ - "fullMethod": "/zitadel.user.v2.UserService/CreateUser", - "instanceID": "344648897353744526", - "orgID": "344648897353810062", - "projectID": "344648897353875598", - "userID": "344648897354465422", - "request": - { - "organizationId": "344648897353810062", - "human": - { - "profile": - { - "givenName": "Minnie", - "familyName": "Mouse", - "nickName": "Mini", - "displayName": "Minnie Mouse", - "preferredLanguage": "en", - "gender": "GENDER_FEMALE" - }, - "email": - { - "email": "mini+test@mouse.com" - } - } - }, - "headers": - { - "Content-Type": - [ - "application/grpc" - ], - "Host": - [ - "localhost:8080" - ], - "X-Forwarded-For": - [ - "::1" - ], - "X-Forwarded-Host": - [ - "localhost:8080" - ] - } -} -``` - -## Conclusion - -You have successfully set up a target and execution to react to API requests in your ZITADEL instance. -This feature can now be used to provision information in between systems or for triggering workflows based on API requests in ZITADEL. -Additionally, you are sure that the request was not tempered with, as the signature was created with the combination of signing key and payload. -Find more information about the actions feature in the [API documentation](/concepts/features/actions_v2). diff --git a/docs/docs/guides/integrate/actions/testing-request-signature.mdx b/docs/docs/guides/integrate/actions/testing-request-signature.mdx new file mode 100644 index 000000000..75faff178 --- /dev/null +++ b/docs/docs/guides/integrate/actions/testing-request-signature.mdx @@ -0,0 +1,242 @@ +--- +title: Verify Payload Integrity +--- + +import Tabs from "@theme/Tabs"; +import TabItem from "@theme/TabItem"; +import CodeBlock from '@theme/CodeBlock'; +import JSON from "!!raw-loader!./go/json" +import JWT from "!!raw-loader!./go/jwt" +import JWE from "!!raw-loader!./go/jwe" + +This guide shows you how to verify the integrity of the received data on your target. +There are three options available, which will be demonstrated in the following sections. +The examples are based on the request action, but the same principles apply to other action types as well. + +## Prerequisites + +Before you start, make sure you have everything set up correctly. + +- You need to be at least a ZITADEL [_IAM_OWNER_](/guides/manage/console/managers) + +:::info +Note that this guide assumes that ZITADEL is running on the same machine as the target and can be reached via `localhost`. +In case you are using a different setup, you need to adjust the target URL accordingly and will need to make sure that the target is reachable from ZITADEL. +::: + +## Payload and Validation Types + +ZITADEL supports the following three types of payload in actions: +- **JSON**: The payload is sent as JSON in the request body. Additionally, a signature header is sent to validate the payload. +This is the simplest form of payload and validation and doesn't require any additional calls to validate the payload. It's also the default type, +which will be used if no type is specified. +- **JWT**: The payload is sent as a JSON Web Token (JWT) in the request body. The JWT is signed with the signing key of the instance. +This allows you to validate the payload by verifying the signature of the JWT using the signing key managed though the webkeys endpoint of the ZITADEL instance, +which allows for easier key rotation and management. Additionally, it's the base for the JWE type, in case you need to encrypt the payload for certain use cases. +- **JWE**: The payload is sent as encrypted JSON Web Token (JWE) in the request body. The JWT is additionally encrypted with the public key provided by you. +This allows you to validate and decrypt the payload by verifying the signature of the JWT using the signing key managed though the webkeys endpoint of the ZITADEL instance, +and decrypting the payload using your private key. This is the most secure form of payload and validation, but requires additional setup to provide the public key to ZITADEL. +This type is recommended if the payload contains sensitive information that should not be exposed to any intermediaries. + +## Create target + +We'll start the endpoint on port '8090' and if we want to use it as webhook, the target can be created as follows: + +See [Create a target](/apis/resources/action_service_v2/action-service-create-target) for more detailed information. + +Specify the `payloadType` according to the implementation you want to test: `PAYLOAD_TYPE_JSON`, `PAYLOAD_TYPE_JWT`, or `PAYLOAD_TYPE_JWE`. + +```shell +curl -L -X POST 'https://$CUSTOM-DOMAIN/v2/actions/targets' \ +-H 'Content-Type: application/json' \ +-H 'Accept: application/json' \ +-H 'Authorization: Bearer ' \ +--data-raw '{ + "name": "local webhook", + "restWebhook": { + "interruptOnError": true + }, + "endpoint": "http://localhost:8090/webhook", + "timeout": "10s", + "payloadType": "PAYLOAD_TYPE_JSON" +}' +``` + +Example response after creating the target: +```json +{ + "id": "344649040681500814", + "creationDate": "2025-10-31T15:00:36.432595dZ", + "signingKey": "somekey" +} +``` +Save the returned ID to set in the execution. If you're intending to use the `PAYLOAD_TYPE_JSON`, additionally store the `signingKey` and use it in the example above. + +### JWE Specific Setup + +If you are using the `PAYLOAD_TYPE_JWE`, you need to provide a public key to ZITADEL so that it can encrypt the payload. + +Create a public/private key pair. You can use the following command to generate an RSA key pair: +```shell +openssl genpkey -algorithm RSA -outform PEM -out private_key.pem -pkeyopt rsa_keygen_bits:2048 +openssl rsa -pubout -in private_key.pem -out public_key.pem +``` + +Then upload the public key to ZITADEL using the following command. Replace `` with the ID of the target you created earlier. +Use the base64 encoded content of the `public_key.pem` file as the value for `publicKey`. +```shell +curl -L -X POST 'https://$CUSTOM-DOMAIN/v2/actions/targets//publickeys' \ +-H 'Content-Type: application/json' \ +-H 'Accept: application/json' \ +-H 'Authorization: Bearer ' \ +--data-raw '{ + "publicKey": "" +}' +``` + +Be sure to also activate the public key for the target using the returned `` from the previous request: +```shell +curl -L -X PUT 'https://$CUSTOM-DOMAIN/v2/actions/targets//publickeys//activate' \ +-H 'Content-Type: application/json' \ +-H 'Accept: application/json' \ +-H 'Authorization: Bearer ' +``` + +## Set execution + +To configure ZITADEL to call the target when an API endpoint is called, you need to set an execution and define the request +condition. + +See [Set an execution](/apis/resources/action_service_v2/action-service-set-execution) for more detailed information. + +```shell +curl -L -X PUT 'https://$CUSTOM-DOMAIN/v2/actions/executions' \ +-H 'Content-Type: application/json' \ +-H 'Accept: application/json' \ +-H 'Authorization: Bearer ' \ +--data-raw '{ + "condition": { + "request": { + "method": "/zitadel.user.v2.UserService/CreateUser" + } + }, + "targets": [ + "" + ] +}' +``` + +## Start example target + +To test the actions feature, you need to create a target that will be called when an API endpoint is called. +You will need to implement a listener that can receive HTTP requests. +For this example, we will use a simple Go HTTP server that will print the received request to standard output. +As mentioned before, this validation can and should be applied to any target implementation. + + + + {JSON} + + + {JWT} + + + {JWE} + + + +:::info +The example above runs only on your local machine (`localhost`). +To test it with Zitadel, you must make your listener reachable from the internet. +You can do this by using **Webhook.site** (see [Creating a Listener with Webhook.site](./webhook-site-setup)). +::: + +## Example call + +Now that you have set up the target and execution, you can test it by creating a user through the Console UI or +by calling the ZITADEL API to create a human user. + +```shell +curl -L -X POST 'https://$CUSTOM-DOMAIN/v2/users/new' \ +-H 'Content-Type: application/json' \ +-H 'Accept: application/json' \ +-H 'Authorization: Bearer ' \ +--data-raw '{ + "organizationId": "344648897353810062", + "human": + { + "profile": + { + "givenName": "Minnie", + "familyName": "Mouse", + "nickName": "Mini", + "displayName": "Minnie Mouse", + "preferredLanguage": "en", + "gender": "GENDER_FEMALE" + }, + "email": + { + "email": "mini+test@mouse.com" + } + } +}' +``` + +Your server should now print out something like the following. Check out +the [Sent information Request](./usage#sent-information-request) payload description. + +```json +{ + "fullMethod": "/zitadel.user.v2.UserService/CreateUser", + "instanceID": "344648897353744526", + "orgID": "344648897353810062", + "projectID": "344648897353875598", + "userID": "344648897354465422", + "request": + { + "organizationId": "344648897353810062", + "human": + { + "profile": + { + "givenName": "Minnie", + "familyName": "Mouse", + "nickName": "Mini", + "displayName": "Minnie Mouse", + "preferredLanguage": "en", + "gender": "GENDER_FEMALE" + }, + "email": + { + "email": "mini+test@mouse.com" + } + } + }, + "headers": + { + "Content-Type": + [ + "application/grpc" + ], + "Host": + [ + "localhost:8080" + ], + "X-Forwarded-For": + [ + "::1" + ], + "X-Forwarded-Host": + [ + "localhost:8080" + ] + } +} +``` + +## Conclusion + +You have successfully set up a target and verified the payload integrity for request actions using your preferred payload type. +You can now extend this setup to other action types and integrate it into your workflows as needed. +Selecting the appropriate payload type ensures that your data is transmitted securely and can be validated effectively on the receiving end. +Find more information about the actions feature in the [API documentation](/concepts/features/actions_v2). diff --git a/docs/sidebars.js b/docs/sidebars.js index e58462ee3..75d717f3f 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -506,10 +506,6 @@ module.exports = { type: "doc", id: "guides/integrate/actions/testing-request-manipulation", }, - { - type: "doc", - id: "guides/integrate/actions/testing-request-signature", - }, { type: "doc", id: "guides/integrate/actions/testing-response", @@ -530,6 +526,10 @@ module.exports = { type: "doc", id: "guides/integrate/actions/testing-event", }, + { + type: "doc", + id: "guides/integrate/actions/testing-request-signature", + }, { type: "doc", id: "guides/integrate/actions/migrate-from-v1", diff --git a/internal/api/api.go b/internal/api/api.go index da9055285..8010a1391 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -207,7 +207,7 @@ func (a *API) registerConnectServer(service server.ConnectServer) { connect_middleware.AuthorizationInterceptor(a.verifier, a.systemAuthZ, a.authConfig), connect_middleware.TranslationHandler(), connect_middleware.QuotaExhaustedInterceptor(a.accessInterceptor.AccessService(), system_pb.SystemService_ServiceDesc.ServiceName), - connect_middleware.ExecutionHandler(a.targetEncryptionAlgorithm), + connect_middleware.ExecutionHandler(a.targetEncryptionAlgorithm, a.queries.GetActiveSigningWebKey), connect_middleware.ValidationHandler(), connect_middleware.ServiceHandler(), connect_middleware.ActivityInterceptor(), diff --git a/internal/api/grpc/action/v2/integration_test/execution_target_test.go b/internal/api/grpc/action/v2/integration_test/execution_target_test.go index cfb9573b9..dc6b9150f 100644 --- a/internal/api/grpc/action/v2/integration_test/execution_target_test.go +++ b/internal/api/grpc/action/v2/integration_test/execution_target_test.go @@ -72,8 +72,9 @@ func TestServer_ExecutionTarget(t *testing.T) { // create target for target changes targetCreatedName := integration.TargetName() targetCreatedURL := "https://nonexistent" + targetCreatedPayloadType := action.PayloadType_PAYLOAD_TYPE_JSON - targetCreated := instance.CreateTarget(ctx, t, targetCreatedName, targetCreatedURL, target_domain.TargetTypeCall, false) + targetCreated := instance.CreateTarget(ctx, t, targetCreatedName, targetCreatedURL, target_domain.TargetTypeCall, false, targetCreatedPayloadType) // request received by target wantRequest := &middleware.ContextInfoRequest{ @@ -89,7 +90,7 @@ func TestServer_ExecutionTarget(t *testing.T) { // replace original request with different targetID urlRequest, closeRequest, calledRequest, _ := integration.TestServerCallProto(wantRequest, 0, http.StatusOK, changedRequest) - targetRequest := waitForTarget(ctx, t, instance, urlRequest, target_domain.TargetTypeCall, false) + targetRequest := waitForTarget(ctx, t, instance, urlRequest, target_domain.TargetTypeCall, false, targetCreatedPayloadType) waitForExecutionOnCondition(ctx, t, instance, conditionRequestFullMethod(fullMethod), []string{targetRequest.GetId()}) @@ -105,9 +106,10 @@ func TestServer_ExecutionTarget(t *testing.T) { InterruptOnError: false, }, }, - Timeout: durationpb.New(5 * time.Second), - Endpoint: targetCreatedURL, - SigningKey: targetCreated.GetSigningKey(), + Timeout: durationpb.New(5 * time.Second), + Endpoint: targetCreatedURL, + SigningKey: targetCreated.GetSigningKey(), + PayloadType: targetCreatedPayloadType, }, } @@ -122,9 +124,10 @@ func TestServer_ExecutionTarget(t *testing.T) { InterruptOnError: false, }, }, - Timeout: durationpb.New(5 * time.Second), - Endpoint: targetCreatedURL, - SigningKey: targetCreated.GetSigningKey(), + Timeout: durationpb.New(5 * time.Second), + Endpoint: targetCreatedURL, + SigningKey: targetCreated.GetSigningKey(), + PayloadType: targetCreatedPayloadType, }, } // content for update @@ -138,9 +141,10 @@ func TestServer_ExecutionTarget(t *testing.T) { InterruptOnError: false, }, }, - Timeout: durationpb.New(5 * time.Second), - Endpoint: targetCreatedURL, - SigningKey: targetCreated.GetSigningKey(), + Timeout: durationpb.New(5 * time.Second), + Endpoint: targetCreatedURL, + SigningKey: targetCreated.GetSigningKey(), + PayloadType: targetCreatedPayloadType, } // response received by target @@ -157,7 +161,7 @@ func TestServer_ExecutionTarget(t *testing.T) { // after request with different targetID, return changed response targetResponseURL, closeResponse, calledResponse, _ := integration.TestServerCallProto(wantResponse, 0, http.StatusOK, changedResponse) - targetResponse := waitForTarget(ctx, t, instance, targetResponseURL, target_domain.TargetTypeCall, false) + targetResponse := waitForTarget(ctx, t, instance, targetResponseURL, target_domain.TargetTypeCall, false, targetCreatedPayloadType) waitForExecutionOnCondition(ctx, t, instance, conditionResponseFullMethod(fullMethod), []string{targetResponse.GetId()}) return func() { closeRequest() @@ -195,7 +199,7 @@ func TestServer_ExecutionTarget(t *testing.T) { wantRequest := &middleware.ContextInfoRequest{FullMethod: fullMethod, InstanceID: instance.ID(), OrgID: orgID, ProjectID: projectID, UserID: userID, Request: middleware.Message{Message: request}} urlRequest, closeRequest, calledRequest, _ := integration.TestServerCallProto(wantRequest, 0, http.StatusInternalServerError, nil) - targetRequest := waitForTarget(ctx, t, instance, urlRequest, target_domain.TargetTypeCall, true) + targetRequest := waitForTarget(ctx, t, instance, urlRequest, target_domain.TargetTypeCall, true, action.PayloadType_PAYLOAD_TYPE_JSON) waitForExecutionOnCondition(ctx, t, instance, conditionRequestFullMethod(fullMethod), []string{targetRequest.GetId()}) // GetTarget with used target request.Id = targetRequest.GetId() @@ -222,8 +226,9 @@ func TestServer_ExecutionTarget(t *testing.T) { // create target for target changes targetCreatedName := integration.TargetName() targetCreatedURL := "https://nonexistent" + targetCreatedPayloadType := action.PayloadType_PAYLOAD_TYPE_JSON - targetCreated := instance.CreateTarget(ctx, t, targetCreatedName, targetCreatedURL, target_domain.TargetTypeCall, false) + targetCreated := instance.CreateTarget(ctx, t, targetCreatedName, targetCreatedURL, target_domain.TargetTypeCall, false, targetCreatedPayloadType) // GetTarget with used target request.Id = targetCreated.GetId() @@ -240,9 +245,10 @@ func TestServer_ExecutionTarget(t *testing.T) { InterruptOnError: false, }, }, - Timeout: durationpb.New(5 * time.Second), - Endpoint: targetCreatedURL, - SigningKey: targetCreated.GetSigningKey(), + Timeout: durationpb.New(5 * time.Second), + Endpoint: targetCreatedURL, + SigningKey: targetCreated.GetSigningKey(), + PayloadType: targetCreatedPayloadType, }, } @@ -259,7 +265,7 @@ func TestServer_ExecutionTarget(t *testing.T) { // after request with different targetID, return changed response targetResponseURL, closeResponse, calledResponse, _ := integration.TestServerCallProto(wantResponse, 0, http.StatusInternalServerError, nil) - targetResponse := waitForTarget(ctx, t, instance, targetResponseURL, target_domain.TargetTypeCall, true) + targetResponse := waitForTarget(ctx, t, instance, targetResponseURL, target_domain.TargetTypeCall, true, targetCreatedPayloadType) waitForExecutionOnCondition(ctx, t, instance, conditionResponseFullMethod(fullMethod), []string{targetResponse.GetId()}) return func() { closeResponse() @@ -307,7 +313,7 @@ func TestServer_ExecutionTarget_Event(t *testing.T) { urlRequest, closeF, calledF, resetF := integration.TestServerCall(nil, 0, http.StatusOK, nil) defer closeF() - targetResponse := waitForTarget(isolatedIAMOwnerCTX, t, instance, urlRequest, target_domain.TargetTypeWebhook, true) + targetResponse := waitForTarget(isolatedIAMOwnerCTX, t, instance, urlRequest, target_domain.TargetTypeWebhook, true, action.PayloadType_PAYLOAD_TYPE_JSON) waitForExecutionOnCondition(isolatedIAMOwnerCTX, t, instance, conditionEvent(event), []string{targetResponse.GetId()}) tests := []struct { @@ -365,7 +371,7 @@ func TestServer_ExecutionTarget_Event_LongerThanTargetTimeout(t *testing.T) { urlRequest, closeF, calledF, resetF := integration.TestServerCall(nil, 5*time.Second, http.StatusOK, nil) defer closeF() - targetResponse := waitForTarget(isolatedIAMOwnerCTX, t, instance, urlRequest, target_domain.TargetTypeWebhook, true) + targetResponse := waitForTarget(isolatedIAMOwnerCTX, t, instance, urlRequest, target_domain.TargetTypeWebhook, true, action.PayloadType_PAYLOAD_TYPE_JSON) waitForExecutionOnCondition(isolatedIAMOwnerCTX, t, instance, conditionEvent(event), []string{targetResponse.GetId()}) tests := []struct { @@ -416,7 +422,7 @@ func TestServer_ExecutionTarget_Event_LongerThanTransactionTimeout(t *testing.T) urlRequest, closeF, calledF, resetF := integration.TestServerCall(nil, 1*time.Second, http.StatusOK, nil) defer closeF() - targetResponse := waitForTarget(isolatedIAMOwnerCTX, t, instance, urlRequest, target_domain.TargetTypeWebhook, true) + targetResponse := waitForTarget(isolatedIAMOwnerCTX, t, instance, urlRequest, target_domain.TargetTypeWebhook, true, action.PayloadType_PAYLOAD_TYPE_JSON) waitForExecutionOnCondition(isolatedIAMOwnerCTX, t, instance, conditionEvent(event), []string{targetResponse.GetId()}) tests := []struct { @@ -499,8 +505,8 @@ func waitForExecutionOnCondition(ctx context.Context, t *testing.T, instance *in }, retryDuration, tick, "timeout waiting for expected execution result") } -func waitForTarget(ctx context.Context, t *testing.T, instance *integration.Instance, endpoint string, ty target_domain.TargetType, interrupt bool) *action.CreateTargetResponse { - resp := instance.CreateTarget(ctx, t, "", endpoint, ty, interrupt) +func waitForTarget(ctx context.Context, t *testing.T, instance *integration.Instance, endpoint string, ty target_domain.TargetType, interrupt bool, payloadType action.PayloadType) *action.CreateTargetResponse { + resp := instance.CreateTarget(ctx, t, "", endpoint, ty, interrupt, payloadType) retryDuration, tick := integration.WaitForAndTickWithMaxDuration(ctx, time.Minute) require.EventuallyWithT(t, func(ttt *assert.CollectT) { @@ -779,7 +785,7 @@ func expectPreUserinfoExecution(ctx context.Context, t *testing.T, instance *int targetURL, closeF, _, _ := integration.TestServerCall(expectedContextInfo, 0, http.StatusOK, response) - targetResp := waitForTarget(ctx, t, instance, targetURL, target_domain.TargetTypeCall, true) + targetResp := waitForTarget(ctx, t, instance, targetURL, target_domain.TargetTypeCall, true, action.PayloadType_PAYLOAD_TYPE_JSON) waitForExecutionOnCondition(ctx, t, instance, conditionFunction("preuserinfo"), []string{targetResp.GetId()}) return userResp.GetUserId(), closeF } @@ -1087,7 +1093,7 @@ func expectPreAccessTokenExecution(ctx context.Context, t *testing.T, instance * targetURL, closeF, _, _ := integration.TestServerCall(expectedContextInfo, 0, http.StatusOK, response) - targetResp := waitForTarget(ctx, t, instance, targetURL, target_domain.TargetTypeCall, true) + targetResp := waitForTarget(ctx, t, instance, targetURL, target_domain.TargetTypeCall, true, action.PayloadType_PAYLOAD_TYPE_JSON) waitForExecutionOnCondition(ctx, t, instance, conditionFunction("preaccesstoken"), []string{targetResp.GetId()}) return userResp.GetUserId(), closeF } @@ -1252,7 +1258,7 @@ func expectPreSAMLResponseExecution(ctx context.Context, t *testing.T, instance targetURL, closeF, _, _ := integration.TestServerCall(expectedContextInfo, 0, http.StatusOK, response) - targetResp := waitForTarget(ctx, t, instance, targetURL, target_domain.TargetTypeCall, true) + targetResp := waitForTarget(ctx, t, instance, targetURL, target_domain.TargetTypeCall, true, action.PayloadType_PAYLOAD_TYPE_JSON) waitForExecutionOnCondition(ctx, t, instance, conditionFunction("presamlresponse"), []string{targetResp.GetId()}) return userResp.GetUserId(), closeF diff --git a/internal/api/grpc/action/v2/integration_test/execution_test.go b/internal/api/grpc/action/v2/integration_test/execution_test.go index 1495a7d07..e6a78a674 100644 --- a/internal/api/grpc/action/v2/integration_test/execution_test.go +++ b/internal/api/grpc/action/v2/integration_test/execution_test.go @@ -18,7 +18,7 @@ import ( func TestServer_SetExecution_Request(t *testing.T) { instance := integration.NewInstance(CTX) isolatedIAMOwnerCTX := instance.WithAuthorizationToken(CTX, integration.UserTypeIAMOwner) - targetResp := instance.CreateTarget(isolatedIAMOwnerCTX, t, "", "https://notexisting", target_domain.TargetTypeWebhook, false) + targetResp := instance.CreateTarget(isolatedIAMOwnerCTX, t, "", "https://notexisting", target_domain.TargetTypeWebhook, false, action.PayloadType_PAYLOAD_TYPE_JSON) tests := []struct { name string @@ -175,7 +175,7 @@ func assertSetExecutionResponse(t *testing.T, creationDate, setDate time.Time, e func TestServer_SetExecution_Response(t *testing.T) { instance := integration.NewInstance(CTX) isolatedIAMOwnerCTX := instance.WithAuthorizationToken(CTX, integration.UserTypeIAMOwner) - targetResp := instance.CreateTarget(isolatedIAMOwnerCTX, t, "", "https://notexisting", target_domain.TargetTypeWebhook, false) + targetResp := instance.CreateTarget(isolatedIAMOwnerCTX, t, "", "https://notexisting", target_domain.TargetTypeWebhook, false, action.PayloadType_PAYLOAD_TYPE_JSON) tests := []struct { name string @@ -319,7 +319,7 @@ func TestServer_SetExecution_Response(t *testing.T) { func TestServer_SetExecution_Event(t *testing.T) { instance := integration.NewInstance(CTX) isolatedIAMOwnerCTX := instance.WithAuthorizationToken(CTX, integration.UserTypeIAMOwner) - targetResp := instance.CreateTarget(isolatedIAMOwnerCTX, t, "", "https://notexisting", target_domain.TargetTypeWebhook, false) + targetResp := instance.CreateTarget(isolatedIAMOwnerCTX, t, "", "https://notexisting", target_domain.TargetTypeWebhook, false, action.PayloadType_PAYLOAD_TYPE_JSON) tests := []struct { name string @@ -482,7 +482,7 @@ func TestServer_SetExecution_Event(t *testing.T) { func TestServer_SetExecution_Function(t *testing.T) { instance := integration.NewInstance(CTX) isolatedIAMOwnerCTX := instance.WithAuthorizationToken(CTX, integration.UserTypeIAMOwner) - targetResp := instance.CreateTarget(isolatedIAMOwnerCTX, t, "", "https://notexisting", target_domain.TargetTypeWebhook, false) + targetResp := instance.CreateTarget(isolatedIAMOwnerCTX, t, "", "https://notexisting", target_domain.TargetTypeWebhook, false, action.PayloadType_PAYLOAD_TYPE_JSON) tests := []struct { name string diff --git a/internal/api/grpc/action/v2/integration_test/query_test.go b/internal/api/grpc/action/v2/integration_test/query_test.go index 02c71a420..2af0b627b 100644 --- a/internal/api/grpc/action/v2/integration_test/query_test.go +++ b/internal/api/grpc/action/v2/integration_test/query_test.go @@ -11,6 +11,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "google.golang.org/protobuf/types/known/durationpb" + "google.golang.org/protobuf/types/known/timestamppb" target_domain "github.com/zitadel/zitadel/internal/execution/target" "github.com/zitadel/zitadel/internal/integration" @@ -54,7 +55,7 @@ func TestServer_GetTarget(t *testing.T) { ctx: isolatedIAMOwnerCTX, dep: func(ctx context.Context, request *action.GetTargetRequest, response *action.GetTargetResponse) error { name := integration.TargetName() - resp := instance.CreateTarget(ctx, t, name, "https://example.com", target_domain.TargetTypeWebhook, false) + resp := instance.CreateTarget(ctx, t, name, "https://example.com", target_domain.TargetTypeWebhook, false, action.PayloadType_PAYLOAD_TYPE_JSON) request.Id = resp.GetId() response.Target.Id = resp.GetId() response.Target.Name = name @@ -71,7 +72,8 @@ func TestServer_GetTarget(t *testing.T) { TargetType: &action.Target_RestWebhook{ RestWebhook: &action.RESTWebhook{}, }, - Timeout: durationpb.New(5 * time.Second), + Timeout: durationpb.New(5 * time.Second), + PayloadType: action.PayloadType_PAYLOAD_TYPE_JSON, }, }, }, @@ -81,7 +83,7 @@ func TestServer_GetTarget(t *testing.T) { ctx: isolatedIAMOwnerCTX, dep: func(ctx context.Context, request *action.GetTargetRequest, response *action.GetTargetResponse) error { name := integration.TargetName() - resp := instance.CreateTarget(ctx, t, name, "https://example.com", target_domain.TargetTypeAsync, false) + resp := instance.CreateTarget(ctx, t, name, "https://example.com", target_domain.TargetTypeAsync, false, action.PayloadType_PAYLOAD_TYPE_JSON) request.Id = resp.GetId() response.Target.Id = resp.GetId() response.Target.Name = name @@ -98,7 +100,8 @@ func TestServer_GetTarget(t *testing.T) { TargetType: &action.Target_RestAsync{ RestAsync: &action.RESTAsync{}, }, - Timeout: durationpb.New(5 * time.Second), + Timeout: durationpb.New(5 * time.Second), + PayloadType: action.PayloadType_PAYLOAD_TYPE_JSON, }, }, }, @@ -108,7 +111,7 @@ func TestServer_GetTarget(t *testing.T) { ctx: isolatedIAMOwnerCTX, dep: func(ctx context.Context, request *action.GetTargetRequest, response *action.GetTargetResponse) error { name := integration.TargetName() - resp := instance.CreateTarget(ctx, t, name, "https://example.com", target_domain.TargetTypeWebhook, true) + resp := instance.CreateTarget(ctx, t, name, "https://example.com", target_domain.TargetTypeWebhook, true, action.PayloadType_PAYLOAD_TYPE_JSON) request.Id = resp.GetId() response.Target.Id = resp.GetId() response.Target.Name = name @@ -127,7 +130,8 @@ func TestServer_GetTarget(t *testing.T) { InterruptOnError: true, }, }, - Timeout: durationpb.New(5 * time.Second), + Timeout: durationpb.New(5 * time.Second), + PayloadType: action.PayloadType_PAYLOAD_TYPE_JSON, }, }, }, @@ -137,7 +141,7 @@ func TestServer_GetTarget(t *testing.T) { ctx: isolatedIAMOwnerCTX, dep: func(ctx context.Context, request *action.GetTargetRequest, response *action.GetTargetResponse) error { name := integration.TargetName() - resp := instance.CreateTarget(ctx, t, name, "https://example.com", target_domain.TargetTypeCall, false) + resp := instance.CreateTarget(ctx, t, name, "https://example.com", target_domain.TargetTypeCall, false, action.PayloadType_PAYLOAD_TYPE_JSON) request.Id = resp.GetId() response.Target.Id = resp.GetId() response.Target.Name = name @@ -156,7 +160,8 @@ func TestServer_GetTarget(t *testing.T) { InterruptOnError: false, }, }, - Timeout: durationpb.New(5 * time.Second), + Timeout: durationpb.New(5 * time.Second), + PayloadType: action.PayloadType_PAYLOAD_TYPE_JSON, }, }, }, @@ -166,7 +171,7 @@ func TestServer_GetTarget(t *testing.T) { ctx: isolatedIAMOwnerCTX, dep: func(ctx context.Context, request *action.GetTargetRequest, response *action.GetTargetResponse) error { name := integration.TargetName() - resp := instance.CreateTarget(ctx, t, name, "https://example.com", target_domain.TargetTypeCall, true) + resp := instance.CreateTarget(ctx, t, name, "https://example.com", target_domain.TargetTypeCall, true, action.PayloadType_PAYLOAD_TYPE_JSON) request.Id = resp.GetId() response.Target.Id = resp.GetId() response.Target.Name = name @@ -185,7 +190,8 @@ func TestServer_GetTarget(t *testing.T) { InterruptOnError: true, }, }, - Timeout: durationpb.New(5 * time.Second), + Timeout: durationpb.New(5 * time.Second), + PayloadType: action.PayloadType_PAYLOAD_TYPE_JSON, }, }, }, @@ -261,7 +267,7 @@ func TestServer_ListTargets(t *testing.T) { ctx: isolatedIAMOwnerCTX, dep: func(ctx context.Context, request *action.ListTargetsRequest, response *action.ListTargetsResponse) { name := integration.TargetName() - resp := instance.CreateTarget(ctx, t, name, "https://example.com", target_domain.TargetTypeWebhook, false) + resp := instance.CreateTarget(ctx, t, name, "https://example.com", target_domain.TargetTypeWebhook, false, action.PayloadType_PAYLOAD_TYPE_JSON) request.Filters[0].Filter = &action.TargetSearchFilter_InTargetIdsFilter{ InTargetIdsFilter: &action.InTargetIDsFilter{ TargetIds: []string{resp.GetId()}, @@ -291,7 +297,8 @@ func TestServer_ListTargets(t *testing.T) { InterruptOnError: false, }, }, - Timeout: durationpb.New(5 * time.Second), + Timeout: durationpb.New(5 * time.Second), + PayloadType: action.PayloadType_PAYLOAD_TYPE_JSON, }, }, }, @@ -301,7 +308,7 @@ func TestServer_ListTargets(t *testing.T) { ctx: isolatedIAMOwnerCTX, dep: func(ctx context.Context, request *action.ListTargetsRequest, response *action.ListTargetsResponse) { name := integration.TargetName() - resp := instance.CreateTarget(ctx, t, name, "https://example.com", target_domain.TargetTypeWebhook, false) + resp := instance.CreateTarget(ctx, t, name, "https://example.com", target_domain.TargetTypeWebhook, false, action.PayloadType_PAYLOAD_TYPE_JSON) request.Filters[0].Filter = &action.TargetSearchFilter_TargetNameFilter{ TargetNameFilter: &action.TargetNameFilter{ TargetName: name, @@ -331,7 +338,8 @@ func TestServer_ListTargets(t *testing.T) { InterruptOnError: false, }, }, - Timeout: durationpb.New(5 * time.Second), + Timeout: durationpb.New(5 * time.Second), + PayloadType: action.PayloadType_PAYLOAD_TYPE_JSON, }, }, }, @@ -344,9 +352,9 @@ func TestServer_ListTargets(t *testing.T) { name1 := integration.TargetName() name2 := integration.TargetName() name3 := integration.TargetName() - resp1 := instance.CreateTarget(ctx, t, name1, "https://example.com", target_domain.TargetTypeWebhook, false) - resp2 := instance.CreateTarget(ctx, t, name2, "https://example.com", target_domain.TargetTypeCall, true) - resp3 := instance.CreateTarget(ctx, t, name3, "https://example.com", target_domain.TargetTypeAsync, false) + resp1 := instance.CreateTarget(ctx, t, name1, "https://example.com", target_domain.TargetTypeWebhook, false, action.PayloadType_PAYLOAD_TYPE_JSON) + resp2 := instance.CreateTarget(ctx, t, name2, "https://example.com", target_domain.TargetTypeCall, true, action.PayloadType_PAYLOAD_TYPE_JWT) + resp3 := instance.CreateTarget(ctx, t, name3, "https://example.com", target_domain.TargetTypeAsync, false, action.PayloadType_PAYLOAD_TYPE_JWE) request.Filters[0].Filter = &action.TargetSearchFilter_InTargetIdsFilter{ InTargetIdsFilter: &action.InTargetIDsFilter{ TargetIds: []string{resp1.GetId(), resp2.GetId(), resp3.GetId()}, @@ -386,7 +394,8 @@ func TestServer_ListTargets(t *testing.T) { TargetType: &action.Target_RestAsync{ RestAsync: &action.RESTAsync{}, }, - Timeout: durationpb.New(5 * time.Second), + Timeout: durationpb.New(5 * time.Second), + PayloadType: action.PayloadType_PAYLOAD_TYPE_JWE, }, { Endpoint: "https://example.com", @@ -395,7 +404,8 @@ func TestServer_ListTargets(t *testing.T) { InterruptOnError: true, }, }, - Timeout: durationpb.New(5 * time.Second), + Timeout: durationpb.New(5 * time.Second), + PayloadType: action.PayloadType_PAYLOAD_TYPE_JWT, }, { Endpoint: "https://example.com", @@ -404,7 +414,8 @@ func TestServer_ListTargets(t *testing.T) { InterruptOnError: false, }, }, - Timeout: durationpb.New(5 * time.Second), + Timeout: durationpb.New(5 * time.Second), + PayloadType: action.PayloadType_PAYLOAD_TYPE_JSON, }, }, }, @@ -445,7 +456,7 @@ func assertPaginationResponse(t *assert.CollectT, expected *filter.PaginationRes func TestServer_ListExecutions(t *testing.T) { instance := integration.NewInstance(CTX) isolatedIAMOwnerCTX := instance.WithAuthorizationToken(CTX, integration.UserTypeIAMOwner) - targetResp := instance.CreateTarget(isolatedIAMOwnerCTX, t, "", "https://example.com", target_domain.TargetTypeWebhook, false) + targetResp := instance.CreateTarget(isolatedIAMOwnerCTX, t, "", "https://example.com", target_domain.TargetTypeWebhook, false, action.PayloadType_PAYLOAD_TYPE_JSON) type args struct { ctx context.Context @@ -523,7 +534,7 @@ func TestServer_ListExecutions(t *testing.T) { args: args{ ctx: isolatedIAMOwnerCTX, dep: func(ctx context.Context, request *action.ListExecutionsRequest, response *action.ListExecutionsResponse) { - target := instance.CreateTarget(isolatedIAMOwnerCTX, t, "", "https://example.com", target_domain.TargetTypeWebhook, false) + target := instance.CreateTarget(isolatedIAMOwnerCTX, t, "", "https://example.com", target_domain.TargetTypeWebhook, false, action.PayloadType_PAYLOAD_TYPE_JSON) // add target as Filter to the request request.Filters[0] = &action.ExecutionSearchFilter{ Filter: &action.ExecutionSearchFilter_TargetFilter{ @@ -781,3 +792,334 @@ func TestServer_ListExecutions(t *testing.T) { }) } } + +func TestServer_ListPublicKeys(t *testing.T) { + instance := integration.NewInstance(CTX) + isolatedIAMOwnerCTX := instance.WithAuthorizationToken(CTX, integration.UserTypeIAMOwner) + + targetID := instance.CreateTarget(isolatedIAMOwnerCTX, t, "", "https://example.com", target_domain.TargetTypeWebhook, false, action.PayloadType_PAYLOAD_TYPE_JSON).GetId() + activeKey, err := instance.Client.ActionV2.AddPublicKey(isolatedIAMOwnerCTX, &action.AddPublicKeyRequest{ + TargetId: targetID, + PublicKey: publicKeyBytes, + }) + require.NoError(t, err) + changeDate, err := instance.Client.ActionV2.ActivatePublicKey(isolatedIAMOwnerCTX, &action.ActivatePublicKeyRequest{ + TargetId: targetID, + KeyId: activeKey.GetKeyId(), + }) + require.NoError(t, err) + expirationDate := timestamppb.New(time.Now().Add(24 * time.Hour)) + inactiveKey, err := instance.Client.ActionV2.AddPublicKey(isolatedIAMOwnerCTX, &action.AddPublicKeyRequest{ + TargetId: targetID, + PublicKey: rsaPublicKeyBytes, + ExpirationDate: expirationDate, + }) + require.NoError(t, err) + anotherKey, err := instance.Client.ActionV2.AddPublicKey(isolatedIAMOwnerCTX, &action.AddPublicKeyRequest{ + TargetId: targetID, + PublicKey: rsaPublicKeyBytes, + }) + require.NoError(t, err) + type args struct { + ctx context.Context + req *action.ListPublicKeysRequest + } + tests := []struct { + name string + args args + want *action.ListPublicKeysResponse + wantErr bool + }{ + { + name: "missing permission", + args: args{ + ctx: instance.WithAuthorizationToken(context.Background(), integration.UserTypeOrgOwner), + req: &action.ListPublicKeysRequest{}, + }, + wantErr: true, + }, + { + name: "invalid target id", + args: args{ + ctx: isolatedIAMOwnerCTX, + req: &action.ListPublicKeysRequest{ + TargetId: "notfound", + }, + }, + want: &action.ListPublicKeysResponse{ + Pagination: &filter.PaginationResponse{ + AppliedLimit: 100, + }, + }, + }, + { + name: "list all", + args: args{ + ctx: isolatedIAMOwnerCTX, + req: &action.ListPublicKeysRequest{ + TargetId: targetID, + }, + }, + want: &action.ListPublicKeysResponse{ + Pagination: &filter.PaginationResponse{ + AppliedLimit: 100, + TotalResult: 3, + }, + PublicKeys: []*action.PublicKey{ + { + KeyId: anotherKey.GetKeyId(), + Active: false, + PublicKey: rsaPublicKeyBytes, + Fingerprint: rsaFingerprint, + ExpirationDate: nil, + CreationDate: anotherKey.GetCreationDate(), + ChangeDate: anotherKey.GetCreationDate(), + }, + { + KeyId: inactiveKey.GetKeyId(), + Active: false, + PublicKey: rsaPublicKeyBytes, + Fingerprint: rsaFingerprint, + ExpirationDate: expirationDate, + CreationDate: inactiveKey.GetCreationDate(), + ChangeDate: inactiveKey.GetCreationDate(), + }, + { + KeyId: activeKey.GetKeyId(), + Active: true, + PublicKey: publicKeyBytes, + Fingerprint: fingerprint, + ExpirationDate: nil, + CreationDate: activeKey.GetCreationDate(), + ChangeDate: changeDate.GetChangeDate(), + }, + }, + }, + }, + { + name: "list only active", + args: args{ + ctx: isolatedIAMOwnerCTX, + req: &action.ListPublicKeysRequest{ + TargetId: targetID, + Filters: []*action.PublicKeySearchFilter{ + { + Filter: &action.PublicKeySearchFilter_ActiveFilter{ + ActiveFilter: true, + }, + }, + }, + }, + }, + want: &action.ListPublicKeysResponse{ + Pagination: &filter.PaginationResponse{ + AppliedLimit: 100, + TotalResult: 1, + }, + PublicKeys: []*action.PublicKey{ + { + KeyId: activeKey.GetKeyId(), + Active: true, + PublicKey: publicKeyBytes, + Fingerprint: fingerprint, + ExpirationDate: nil, + CreationDate: activeKey.GetCreationDate(), + ChangeDate: changeDate.GetChangeDate(), + }, + }, + }, + }, + { + name: "list only inactive", + args: args{ + ctx: isolatedIAMOwnerCTX, + req: &action.ListPublicKeysRequest{ + TargetId: targetID, + Filters: []*action.PublicKeySearchFilter{ + { + Filter: &action.PublicKeySearchFilter_ActiveFilter{ + ActiveFilter: false, + }, + }, + }, + }, + }, + want: &action.ListPublicKeysResponse{ + Pagination: &filter.PaginationResponse{ + AppliedLimit: 100, + TotalResult: 2, + }, + PublicKeys: []*action.PublicKey{ + { + KeyId: anotherKey.GetKeyId(), + Active: false, + PublicKey: rsaPublicKeyBytes, + Fingerprint: rsaFingerprint, + ExpirationDate: nil, + CreationDate: anotherKey.GetCreationDate(), + ChangeDate: anotherKey.GetCreationDate(), + }, + { + KeyId: inactiveKey.GetKeyId(), + Active: false, + PublicKey: rsaPublicKeyBytes, + Fingerprint: rsaFingerprint, + ExpirationDate: expirationDate, + CreationDate: inactiveKey.GetCreationDate(), + ChangeDate: inactiveKey.GetCreationDate(), + }, + }, + }, + }, + { + name: "list with expiration date filter (boefore or equals)", + args: args{ + ctx: isolatedIAMOwnerCTX, + req: &action.ListPublicKeysRequest{ + TargetId: targetID, + Filters: []*action.PublicKeySearchFilter{ + { + Filter: &action.PublicKeySearchFilter_ExpirationDateFilter{ + ExpirationDateFilter: &filter.TimestampFilter{ + Timestamp: expirationDate, + Method: filter.TimestampFilterMethod_TIMESTAMP_FILTER_METHOD_BEFORE_OR_EQUALS, + }, + }, + }, + }, + }, + }, + want: &action.ListPublicKeysResponse{ + Pagination: &filter.PaginationResponse{ + AppliedLimit: 100, + TotalResult: 1, + }, + PublicKeys: []*action.PublicKey{ + { + KeyId: inactiveKey.GetKeyId(), + Active: false, + PublicKey: rsaPublicKeyBytes, + Fingerprint: rsaFingerprint, + ExpirationDate: expirationDate, + CreationDate: inactiveKey.GetCreationDate(), + ChangeDate: inactiveKey.GetCreationDate(), + }, + }, + }, + }, + { + name: "list with expiration date filter (after incl. no expiration)", + args: args{ + ctx: isolatedIAMOwnerCTX, + req: &action.ListPublicKeysRequest{ + TargetId: targetID, + Filters: []*action.PublicKeySearchFilter{ + { + Filter: &action.PublicKeySearchFilter_ExpirationDateFilter{ + ExpirationDateFilter: &filter.TimestampFilter{ + Timestamp: expirationDate, + Method: filter.TimestampFilterMethod_TIMESTAMP_FILTER_METHOD_AFTER, + }, + }, + }, + }, + }, + }, + want: &action.ListPublicKeysResponse{ + Pagination: &filter.PaginationResponse{ + AppliedLimit: 100, + TotalResult: 2, + }, + PublicKeys: []*action.PublicKey{ + { + KeyId: anotherKey.GetKeyId(), + Active: false, + PublicKey: rsaPublicKeyBytes, + Fingerprint: rsaFingerprint, + ExpirationDate: nil, + CreationDate: anotherKey.GetCreationDate(), + ChangeDate: anotherKey.GetCreationDate(), + }, + { + KeyId: activeKey.GetKeyId(), + Active: true, + PublicKey: publicKeyBytes, + Fingerprint: fingerprint, + ExpirationDate: nil, + CreationDate: activeKey.GetCreationDate(), + ChangeDate: changeDate.GetChangeDate(), + }, + }, + }, + }, + { + name: "list by key id", + args: args{ + ctx: isolatedIAMOwnerCTX, + req: &action.ListPublicKeysRequest{ + TargetId: targetID, + Filters: []*action.PublicKeySearchFilter{ + { + Filter: &action.PublicKeySearchFilter_KeyIdsFilter{ + KeyIdsFilter: &filter.InIDsFilter{ + Ids: []string{activeKey.GetKeyId(), anotherKey.GetKeyId()}, + }, + }, + }, + }, + SortingColumn: action.PublicKeyFieldName_PUBLIC_KEY_FIELD_NAME_CHANGE_DATE, + Pagination: &filter.PaginationRequest{ + Limit: 1, + Offset: 1, + Asc: true, + }, + }, + }, + want: &action.ListPublicKeysResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 2, + AppliedLimit: 1, + }, + PublicKeys: []*action.PublicKey{ + { + KeyId: anotherKey.GetKeyId(), + Active: false, + PublicKey: rsaPublicKeyBytes, + Fingerprint: rsaFingerprint, + ExpirationDate: nil, + CreationDate: anotherKey.GetCreationDate(), + ChangeDate: anotherKey.GetCreationDate(), + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(isolatedIAMOwnerCTX, 10*time.Second) + require.EventuallyWithT(t, func(ttt *assert.CollectT) { + got, err := instance.Client.ActionV2.ListPublicKeys(tt.args.ctx, tt.args.req) + if tt.wantErr { + assert.Error(ttt, err, "Error: "+err.Error()) + return + } + assert.NoError(ttt, err) + assertPublicKeys(ttt, tt.want, got) + }, retryDuration, tick, "timeout waiting for expected public keys") + }) + } +} + +func assertPublicKeys(ttt *assert.CollectT, want *action.ListPublicKeysResponse, got *action.ListPublicKeysResponse) { + assert.EqualExportedValues(ttt, want.Pagination, got.Pagination) + assert.Len(ttt, got.PublicKeys, len(want.PublicKeys)) + for i := range want.PublicKeys { + assert.Equal(ttt, want.PublicKeys[i].GetKeyId(), got.PublicKeys[i].GetKeyId()) + assert.Equal(ttt, want.PublicKeys[i].GetActive(), got.PublicKeys[i].GetActive()) + assert.Equal(ttt, want.PublicKeys[i].GetFingerprint(), got.PublicKeys[i].GetFingerprint()) + assert.Equal(ttt, want.PublicKeys[i].GetPublicKey(), got.PublicKeys[i].GetPublicKey()) + assertDateWithinRangeClockSkew(ttt, got.PublicKeys[i].GetExpirationDate().AsTime(), want.PublicKeys[i].GetExpirationDate().AsTime(), want.PublicKeys[i].GetExpirationDate().AsTime()) + assertDateWithinRangeClockSkew(ttt, got.PublicKeys[i].GetCreationDate().AsTime(), want.PublicKeys[i].GetCreationDate().AsTime(), want.PublicKeys[i].GetCreationDate().AsTime()) + assertDateWithinRangeClockSkew(ttt, got.PublicKeys[i].GetChangeDate().AsTime(), want.PublicKeys[i].GetChangeDate().AsTime(), want.PublicKeys[i].GetChangeDate().AsTime()) + } +} diff --git a/internal/api/grpc/action/v2/integration_test/target_test.go b/internal/api/grpc/action/v2/integration_test/target_test.go index bf95307d3..c809dc384 100644 --- a/internal/api/grpc/action/v2/integration_test/target_test.go +++ b/internal/api/grpc/action/v2/integration_test/target_test.go @@ -4,13 +4,25 @@ package action_test import ( "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "fmt" "testing" "time" "github.com/muhlemmer/gu" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "google.golang.org/protobuf/types/known/durationpb" + "google.golang.org/protobuf/types/known/timestamppb" + "github.com/zitadel/zitadel/internal/crypto" target_domain "github.com/zitadel/zitadel/internal/execution/target" "github.com/zitadel/zitadel/internal/integration" "github.com/zitadel/zitadel/pkg/grpc/action/v2" @@ -25,7 +37,7 @@ func TestServer_CreateTarget(t *testing.T) { signingKey bool } alreadyExistingTargetName := integration.TargetName() - instance.CreateTarget(isolatedIAMOwnerCTX, t, alreadyExistingTargetName, "https://example.com", target_domain.TargetTypeAsync, false) + instance.CreateTarget(isolatedIAMOwnerCTX, t, alreadyExistingTargetName, "https://example.com", target_domain.TargetTypeAsync, false, action.PayloadType_PAYLOAD_TYPE_JSON) tests := []struct { name string ctx context.Context @@ -115,7 +127,8 @@ func TestServer_CreateTarget(t *testing.T) { TargetType: &action.CreateTargetRequest_RestAsync{ RestAsync: &action.RESTAsync{}, }, - Timeout: durationpb.New(10 * time.Second), + Timeout: durationpb.New(10 * time.Second), + PayloadType: action.PayloadType_PAYLOAD_TYPE_JSON, }, want: want{ id: true, @@ -134,7 +147,8 @@ func TestServer_CreateTarget(t *testing.T) { InterruptOnError: false, }, }, - Timeout: durationpb.New(10 * time.Second), + Timeout: durationpb.New(10 * time.Second), + PayloadType: action.PayloadType_PAYLOAD_TYPE_JSON, }, want: want{ id: true, @@ -153,7 +167,8 @@ func TestServer_CreateTarget(t *testing.T) { InterruptOnError: true, }, }, - Timeout: durationpb.New(10 * time.Second), + Timeout: durationpb.New(10 * time.Second), + PayloadType: action.PayloadType_PAYLOAD_TYPE_JSON, }, want: want{ id: true, @@ -172,7 +187,8 @@ func TestServer_CreateTarget(t *testing.T) { InterruptOnError: false, }, }, - Timeout: durationpb.New(10 * time.Second), + Timeout: durationpb.New(10 * time.Second), + PayloadType: action.PayloadType_PAYLOAD_TYPE_JSON, }, want: want{ id: true, @@ -192,7 +208,8 @@ func TestServer_CreateTarget(t *testing.T) { InterruptOnError: true, }, }, - Timeout: durationpb.New(10 * time.Second), + Timeout: durationpb.New(10 * time.Second), + PayloadType: action.PayloadType_PAYLOAD_TYPE_JSON, }, want: want{ id: true, @@ -262,7 +279,7 @@ func TestServer_UpdateTarget(t *testing.T) { { name: "missing permission", prepare: func(request *action.UpdateTargetRequest) { - targetID := instance.CreateTarget(isolatedIAMOwnerCTX, t, "", "https://example.com", target_domain.TargetTypeWebhook, false).GetId() + targetID := instance.CreateTarget(isolatedIAMOwnerCTX, t, "", "https://example.com", target_domain.TargetTypeWebhook, false, action.PayloadType_PAYLOAD_TYPE_JSON).GetId() request.Id = targetID }, args: args{ @@ -289,7 +306,7 @@ func TestServer_UpdateTarget(t *testing.T) { { name: "no change, ok", prepare: func(request *action.UpdateTargetRequest) { - targetID := instance.CreateTarget(isolatedIAMOwnerCTX, t, "", "https://example.com", target_domain.TargetTypeWebhook, false).GetId() + targetID := instance.CreateTarget(isolatedIAMOwnerCTX, t, "", "https://example.com", target_domain.TargetTypeWebhook, false, action.PayloadType_PAYLOAD_TYPE_JSON).GetId() request.Id = targetID }, args: args{ @@ -307,7 +324,7 @@ func TestServer_UpdateTarget(t *testing.T) { { name: "change name, ok", prepare: func(request *action.UpdateTargetRequest) { - targetID := instance.CreateTarget(isolatedIAMOwnerCTX, t, "", "https://example.com", target_domain.TargetTypeWebhook, false).GetId() + targetID := instance.CreateTarget(isolatedIAMOwnerCTX, t, "", "https://example.com", target_domain.TargetTypeWebhook, false, action.PayloadType_PAYLOAD_TYPE_JSON).GetId() request.Id = targetID }, args: args{ @@ -325,7 +342,7 @@ func TestServer_UpdateTarget(t *testing.T) { { name: "regenerate signingkey, ok", prepare: func(request *action.UpdateTargetRequest) { - targetID := instance.CreateTarget(isolatedIAMOwnerCTX, t, "", "https://example.com", target_domain.TargetTypeWebhook, false).GetId() + targetID := instance.CreateTarget(isolatedIAMOwnerCTX, t, "", "https://example.com", target_domain.TargetTypeWebhook, false, action.PayloadType_PAYLOAD_TYPE_JSON).GetId() request.Id = targetID }, args: args{ @@ -343,7 +360,7 @@ func TestServer_UpdateTarget(t *testing.T) { { name: "change type, ok", prepare: func(request *action.UpdateTargetRequest) { - targetID := instance.CreateTarget(isolatedIAMOwnerCTX, t, "", "https://example.com", target_domain.TargetTypeWebhook, false).GetId() + targetID := instance.CreateTarget(isolatedIAMOwnerCTX, t, "", "https://example.com", target_domain.TargetTypeWebhook, false, action.PayloadType_PAYLOAD_TYPE_JSON).GetId() request.Id = targetID }, args: args{ @@ -365,7 +382,7 @@ func TestServer_UpdateTarget(t *testing.T) { { name: "change url, ok", prepare: func(request *action.UpdateTargetRequest) { - targetID := instance.CreateTarget(isolatedIAMOwnerCTX, t, "", "https://example.com", target_domain.TargetTypeWebhook, false).GetId() + targetID := instance.CreateTarget(isolatedIAMOwnerCTX, t, "", "https://example.com", target_domain.TargetTypeWebhook, false, action.PayloadType_PAYLOAD_TYPE_JSON).GetId() request.Id = targetID }, args: args{ @@ -383,7 +400,7 @@ func TestServer_UpdateTarget(t *testing.T) { { name: "change timeout, ok", prepare: func(request *action.UpdateTargetRequest) { - targetID := instance.CreateTarget(isolatedIAMOwnerCTX, t, "", "https://example.com", target_domain.TargetTypeWebhook, false).GetId() + targetID := instance.CreateTarget(isolatedIAMOwnerCTX, t, "", "https://example.com", target_domain.TargetTypeWebhook, false, action.PayloadType_PAYLOAD_TYPE_JSON).GetId() request.Id = targetID }, args: args{ @@ -398,10 +415,28 @@ func TestServer_UpdateTarget(t *testing.T) { signingKey: false, }, }, + { + name: "change timeout, ok", + prepare: func(request *action.UpdateTargetRequest) { + targetID := instance.CreateTarget(isolatedIAMOwnerCTX, t, "", "https://example.com", target_domain.TargetTypeWebhook, false, action.PayloadType_PAYLOAD_TYPE_JSON).GetId() + request.Id = targetID + }, + args: args{ + ctx: isolatedIAMOwnerCTX, + req: &action.UpdateTargetRequest{ + PayloadType: action.PayloadType_PAYLOAD_TYPE_JWT, + }, + }, + want: want{ + change: true, + changeDate: true, + signingKey: false, + }, + }, { name: "change type async, ok", prepare: func(request *action.UpdateTargetRequest) { - targetID := instance.CreateTarget(isolatedIAMOwnerCTX, t, "", "https://example.com", target_domain.TargetTypeAsync, false).GetId() + targetID := instance.CreateTarget(isolatedIAMOwnerCTX, t, "", "https://example.com", target_domain.TargetTypeAsync, false, action.PayloadType_PAYLOAD_TYPE_JSON).GetId() request.Id = targetID }, args: args{ @@ -497,7 +532,7 @@ func TestServer_DeleteTarget(t *testing.T) { ctx: iamOwnerCtx, prepare: func(request *action.DeleteTargetRequest) (time.Time, time.Time) { creationDate := time.Now().UTC() - targetID := instance.CreateTarget(iamOwnerCtx, t, "", "https://example.com", target_domain.TargetTypeWebhook, false).GetId() + targetID := instance.CreateTarget(iamOwnerCtx, t, "", "https://example.com", target_domain.TargetTypeWebhook, false, action.PayloadType_PAYLOAD_TYPE_JSON).GetId() request.Id = targetID return creationDate, time.Time{} }, @@ -509,7 +544,7 @@ func TestServer_DeleteTarget(t *testing.T) { ctx: iamOwnerCtx, prepare: func(request *action.DeleteTargetRequest) (time.Time, time.Time) { creationDate := time.Now().UTC() - targetID := instance.CreateTarget(iamOwnerCtx, t, "", "https://example.com", target_domain.TargetTypeWebhook, false).GetId() + targetID := instance.CreateTarget(iamOwnerCtx, t, "", "https://example.com", target_domain.TargetTypeWebhook, false, action.PayloadType_PAYLOAD_TYPE_JSON).GetId() request.Id = targetID instance.DeleteTarget(iamOwnerCtx, t, targetID) return creationDate, time.Now().UTC() @@ -546,3 +581,496 @@ func assertDeleteTargetResponse(t *testing.T, creationDate, deletionDate time.Ti assert.Nil(t, actualResp.DeletionDate) } } + +var ( + publicKey = func() *ecdsa.PublicKey { + privateKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + return &privateKey.PublicKey + }() + publicKeyBytes = func() []byte { + pubBytes, _ := x509.MarshalPKIXPublicKey(publicKey) + return pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubBytes}) + }() + fingerprint = func() string { + block, _ := x509.MarshalPKIXPublicKey(publicKey) + hash := sha256.Sum256(block) + return fmt.Sprintf("SHA256:%s", base64.RawStdEncoding.EncodeToString(hash[:])) + }() + + rsaPublicKey = func() *rsa.PublicKey { + privateKey, _ := rsa.GenerateKey(rand.Reader, 2048) + return &privateKey.PublicKey + }() + rsaPublicKeyBytes = func() []byte { + data, _ := crypto.PublicKeyToBytes(rsaPublicKey) + return data + }() + rsaFingerprint = func() string { + block, _ := x509.MarshalPKIXPublicKey(rsaPublicKey) + hash := sha256.Sum256(block) + return fmt.Sprintf("SHA256:%s", base64.RawStdEncoding.EncodeToString(hash[:])) + }() +) + +func TestServer_AddPublicKey(t *testing.T) { + instance := integration.NewInstance(CTX) + isolatedIAMOwnerCTX := instance.WithAuthorizationToken(CTX, integration.UserTypeIAMOwner) + type want struct { + id bool + creationDate bool + } + tests := []struct { + name string + ctx context.Context + req *action.AddPublicKeyRequest + want want + wantErr bool + }{ + { + name: "missing permission", + ctx: instance.WithAuthorizationToken(context.Background(), integration.UserTypeOrgOwner), + req: &action.AddPublicKeyRequest{ + TargetId: "targetID", + PublicKey: publicKeyBytes, + ExpirationDate: timestamppb.New(time.Now().UTC().Add(time.Hour)), + }, + wantErr: true, + }, + { + name: "empty target id", + ctx: isolatedIAMOwnerCTX, + req: &action.AddPublicKeyRequest{ + TargetId: "", + PublicKey: publicKeyBytes, + ExpirationDate: timestamppb.New(time.Now().UTC().Add(time.Hour)), + }, + wantErr: true, + }, + { + name: "empty public key", + ctx: isolatedIAMOwnerCTX, + req: &action.AddPublicKeyRequest{ + TargetId: "targetID", + PublicKey: nil, + ExpirationDate: timestamppb.New(time.Now().UTC().Add(time.Hour)), + }, + wantErr: true, + }, + { + name: "invalid public key", + ctx: isolatedIAMOwnerCTX, + req: &action.AddPublicKeyRequest{ + TargetId: "targetID", + PublicKey: []byte("invalid"), + ExpirationDate: timestamppb.New(time.Now().UTC().Add(time.Hour)), + }, + wantErr: true, + }, + { + name: "expiration date in the past", + ctx: isolatedIAMOwnerCTX, + req: &action.AddPublicKeyRequest{ + TargetId: "targetID", + PublicKey: publicKeyBytes, + ExpirationDate: timestamppb.New(time.Now().UTC().Add(-1 * time.Hour)), + }, + wantErr: true, + }, + { + name: "target not existing", + ctx: isolatedIAMOwnerCTX, + req: &action.AddPublicKeyRequest{ + TargetId: "notexisting", + PublicKey: publicKeyBytes, + ExpirationDate: timestamppb.New(time.Now().UTC().Add(time.Hour)), + }, + wantErr: true, + }, + { + name: "add public key, ok", + ctx: isolatedIAMOwnerCTX, + req: func() *action.AddPublicKeyRequest { + targetID := instance.CreateTarget(isolatedIAMOwnerCTX, t, "", "https://example.com", target_domain.TargetTypeWebhook, false, action.PayloadType_PAYLOAD_TYPE_JSON).GetId() + return &action.AddPublicKeyRequest{ + TargetId: targetID, + PublicKey: publicKeyBytes, + ExpirationDate: timestamppb.New(time.Now().UTC().Add(time.Hour)), + } + }(), + want: want{ + id: true, + creationDate: true, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + start := time.Now() + got, err := instance.Client.ActionV2.AddPublicKey(tt.ctx, tt.req) + if tt.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assert.NotEmpty(t, got.GetKeyId()) + assertDateWithinRangeClockSkew(t, got.GetCreationDate().AsTime(), start, time.Now()) + }) + } +} + +func TestServer_ActivatePublicKey(t *testing.T) { + instance := integration.NewInstance(CTX) + isolatedIAMOwnerCTX := instance.WithAuthorizationToken(CTX, integration.UserTypeIAMOwner) + tests := []struct { + name string + ctx context.Context + req *action.ActivatePublicKeyRequest + wantErr bool + }{ + { + name: "missing permission", + ctx: instance.WithAuthorizationToken(context.Background(), integration.UserTypeOrgOwner), + req: &action.ActivatePublicKeyRequest{ + TargetId: "targetID", + KeyId: "keyID", + }, + wantErr: true, + }, + { + name: "empty target id", + ctx: isolatedIAMOwnerCTX, + req: &action.ActivatePublicKeyRequest{ + TargetId: "", + KeyId: "keyID", + }, + wantErr: true, + }, + { + name: "empty key id", + ctx: isolatedIAMOwnerCTX, + req: &action.ActivatePublicKeyRequest{ + TargetId: "targetID", + KeyId: "", + }, + wantErr: true, + }, + { + name: "target not existing", + ctx: isolatedIAMOwnerCTX, + req: &action.ActivatePublicKeyRequest{ + TargetId: "notexisting", + KeyId: "keyID", + }, + wantErr: true, + }, + { + name: "key not existing", + ctx: isolatedIAMOwnerCTX, + req: func() *action.ActivatePublicKeyRequest { + targetID := instance.CreateTarget(isolatedIAMOwnerCTX, t, "", "https://example.com", target_domain.TargetTypeWebhook, false, action.PayloadType_PAYLOAD_TYPE_JSON).GetId() + return &action.ActivatePublicKeyRequest{ + TargetId: targetID, + KeyId: "notexisting", + } + }(), + wantErr: true, + }, + { + name: "already expired key", + ctx: isolatedIAMOwnerCTX, + req: func() *action.ActivatePublicKeyRequest { + targetID := instance.CreateTarget(isolatedIAMOwnerCTX, t, "", "https://example.com", target_domain.TargetTypeWebhook, false, action.PayloadType_PAYLOAD_TYPE_JSON).GetId() + resp, err := instance.Client.ActionV2.AddPublicKey(isolatedIAMOwnerCTX, &action.AddPublicKeyRequest{ + TargetId: targetID, + PublicKey: publicKeyBytes, + ExpirationDate: timestamppb.New(time.Now().Add(1 * time.Second)), + }) + require.NoError(t, err) + time.Sleep(2 * time.Second) + return &action.ActivatePublicKeyRequest{ + TargetId: targetID, + KeyId: resp.GetKeyId(), + } + }(), + wantErr: true, + }, + { + name: "already activated key", + ctx: isolatedIAMOwnerCTX, + req: func() *action.ActivatePublicKeyRequest { + targetID := instance.CreateTarget(isolatedIAMOwnerCTX, t, "", "https://example.com", target_domain.TargetTypeWebhook, false, action.PayloadType_PAYLOAD_TYPE_JSON).GetId() + resp, err := instance.Client.ActionV2.AddPublicKey(isolatedIAMOwnerCTX, &action.AddPublicKeyRequest{ + TargetId: targetID, + PublicKey: publicKeyBytes, + }) + require.NoError(t, err) + _, err = instance.Client.ActionV2.ActivatePublicKey(isolatedIAMOwnerCTX, &action.ActivatePublicKeyRequest{ + TargetId: targetID, + KeyId: resp.GetKeyId(), + }) + require.NoError(t, err) + return &action.ActivatePublicKeyRequest{ + TargetId: targetID, + KeyId: resp.GetKeyId(), + } + }(), + wantErr: false, + }, + { + name: "activate key, ok", + ctx: isolatedIAMOwnerCTX, + req: func() *action.ActivatePublicKeyRequest { + targetID := instance.CreateTarget(isolatedIAMOwnerCTX, t, "", "https://example.com", target_domain.TargetTypeWebhook, false, action.PayloadType_PAYLOAD_TYPE_JSON).GetId() + resp, err := instance.Client.ActionV2.AddPublicKey(isolatedIAMOwnerCTX, &action.AddPublicKeyRequest{ + TargetId: targetID, + PublicKey: publicKeyBytes, + ExpirationDate: timestamppb.New(time.Now().UTC().Add(time.Hour)), + }) + require.NoError(t, err) + return &action.ActivatePublicKeyRequest{ + TargetId: targetID, + KeyId: resp.GetKeyId(), + } + }(), + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + start := time.Now() + got, err := instance.Client.ActionV2.ActivatePublicKey(tt.ctx, tt.req) + if tt.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assertDateWithinRangeClockSkew(t, got.GetChangeDate().AsTime(), start, time.Now()) + }) + } +} + +func TestServer_DeactivatePublicKey(t *testing.T) { + instance := integration.NewInstance(CTX) + isolatedIAMOwnerCTX := instance.WithAuthorizationToken(CTX, integration.UserTypeIAMOwner) + tests := []struct { + name string + ctx context.Context + req *action.DeactivatePublicKeyRequest + wantErr bool + }{ + { + name: "missing permission", + ctx: instance.WithAuthorizationToken(context.Background(), integration.UserTypeOrgOwner), + req: &action.DeactivatePublicKeyRequest{ + TargetId: "targetID", + KeyId: "keyID", + }, + wantErr: true, + }, + { + name: "empty target id", + ctx: isolatedIAMOwnerCTX, + req: &action.DeactivatePublicKeyRequest{ + TargetId: "", + KeyId: "keyID", + }, + wantErr: true, + }, + { + name: "empty key id", + ctx: isolatedIAMOwnerCTX, + req: &action.DeactivatePublicKeyRequest{ + TargetId: "targetID", + KeyId: "", + }, + wantErr: true, + }, + { + name: "target not existing", + ctx: isolatedIAMOwnerCTX, + req: &action.DeactivatePublicKeyRequest{ + TargetId: "notexisting", + KeyId: "keyID", + }, + wantErr: true, + }, + { + name: "key not existing", + ctx: isolatedIAMOwnerCTX, + req: func() *action.DeactivatePublicKeyRequest { + targetID := instance.CreateTarget(isolatedIAMOwnerCTX, t, "", "https://example.com", target_domain.TargetTypeWebhook, false, action.PayloadType_PAYLOAD_TYPE_JSON).GetId() + return &action.DeactivatePublicKeyRequest{ + TargetId: targetID, + KeyId: "notexisting", + } + }(), + wantErr: true, + }, + { + name: "already deactivated key", + ctx: isolatedIAMOwnerCTX, + req: func() *action.DeactivatePublicKeyRequest { + targetID := instance.CreateTarget(isolatedIAMOwnerCTX, t, "", "https://example.com", target_domain.TargetTypeWebhook, false, action.PayloadType_PAYLOAD_TYPE_JSON).GetId() + resp, err := instance.Client.ActionV2.AddPublicKey(isolatedIAMOwnerCTX, &action.AddPublicKeyRequest{ + TargetId: targetID, + PublicKey: publicKeyBytes, + ExpirationDate: timestamppb.New(time.Now().UTC().Add(time.Hour)), + }) + require.NoError(t, err) + return &action.DeactivatePublicKeyRequest{ + TargetId: targetID, + KeyId: resp.GetKeyId(), + } + }(), + wantErr: false, + }, + { + name: "deactivate key, ok", + ctx: isolatedIAMOwnerCTX, + req: func() *action.DeactivatePublicKeyRequest { + targetID := instance.CreateTarget(isolatedIAMOwnerCTX, t, "", "https://example.com", target_domain.TargetTypeWebhook, false, action.PayloadType_PAYLOAD_TYPE_JSON).GetId() + resp, err := instance.Client.ActionV2.AddPublicKey(isolatedIAMOwnerCTX, &action.AddPublicKeyRequest{ + TargetId: targetID, + PublicKey: publicKeyBytes, + ExpirationDate: timestamppb.New(time.Now().UTC().Add(time.Hour)), + }) + require.NoError(t, err) + _, err = instance.Client.ActionV2.ActivatePublicKey(isolatedIAMOwnerCTX, &action.ActivatePublicKeyRequest{ + TargetId: targetID, + KeyId: resp.GetKeyId(), + }) + require.NoError(t, err) + return &action.DeactivatePublicKeyRequest{ + TargetId: targetID, + KeyId: resp.GetKeyId(), + } + }(), + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + start := time.Now() + got, err := instance.Client.ActionV2.DeactivatePublicKey(tt.ctx, tt.req) + if tt.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assertDateWithinRangeClockSkew(t, got.GetChangeDate().AsTime(), start, time.Now()) + }) + } +} + +func TestServer_RemovePublicKey(t *testing.T) { + instance := integration.NewInstance(CTX) + isolatedIAMOwnerCTX := instance.WithAuthorizationToken(CTX, integration.UserTypeIAMOwner) + tests := []struct { + name string + ctx context.Context + req *action.RemovePublicKeyRequest + wantErr bool + }{ + { + name: "missing permission", + ctx: instance.WithAuthorizationToken(context.Background(), integration.UserTypeOrgOwner), + req: &action.RemovePublicKeyRequest{ + TargetId: "targetID", + KeyId: "keyID", + }, + wantErr: true, + }, + { + name: "empty target id", + ctx: isolatedIAMOwnerCTX, + req: &action.RemovePublicKeyRequest{ + TargetId: "", + KeyId: "keyID", + }, + wantErr: true, + }, + { + name: "empty key id", + ctx: isolatedIAMOwnerCTX, + req: &action.RemovePublicKeyRequest{ + TargetId: "targetID", + KeyId: "", + }, + wantErr: true, + }, + { + name: "target not existing", + ctx: isolatedIAMOwnerCTX, + req: &action.RemovePublicKeyRequest{ + TargetId: "notexisting", + KeyId: "keyID", + }, + wantErr: false, + }, + { + name: "key not existing", + ctx: isolatedIAMOwnerCTX, + req: func() *action.RemovePublicKeyRequest { + targetID := instance.CreateTarget(isolatedIAMOwnerCTX, t, "", "https://example.com", target_domain.TargetTypeWebhook, false, action.PayloadType_PAYLOAD_TYPE_JSON).GetId() + return &action.RemovePublicKeyRequest{ + TargetId: targetID, + KeyId: "notexisting", + } + }(), + wantErr: false, + }, + { + name: "remove activated key", + ctx: isolatedIAMOwnerCTX, + req: func() *action.RemovePublicKeyRequest { + targetID := instance.CreateTarget(isolatedIAMOwnerCTX, t, "", "https://example.com", target_domain.TargetTypeWebhook, false, action.PayloadType_PAYLOAD_TYPE_JSON).GetId() + resp, err := instance.Client.ActionV2.AddPublicKey(isolatedIAMOwnerCTX, &action.AddPublicKeyRequest{ + TargetId: targetID, + PublicKey: publicKeyBytes, + }) + require.NoError(t, err) + _, err = instance.Client.ActionV2.ActivatePublicKey(isolatedIAMOwnerCTX, &action.ActivatePublicKeyRequest{ + TargetId: targetID, + KeyId: resp.GetKeyId(), + }) + require.NoError(t, err) + return &action.RemovePublicKeyRequest{ + TargetId: targetID, + KeyId: resp.GetKeyId(), + } + }(), + wantErr: true, + }, + { + name: "remove key, ok", + ctx: isolatedIAMOwnerCTX, + req: func() *action.RemovePublicKeyRequest { + targetID := instance.CreateTarget(isolatedIAMOwnerCTX, t, "", "https://example.com", target_domain.TargetTypeWebhook, false, action.PayloadType_PAYLOAD_TYPE_JSON).GetId() + resp, err := instance.Client.ActionV2.AddPublicKey(isolatedIAMOwnerCTX, &action.AddPublicKeyRequest{ + TargetId: targetID, + PublicKey: publicKeyBytes, + }) + require.NoError(t, err) + return &action.RemovePublicKeyRequest{ + TargetId: targetID, + KeyId: resp.GetKeyId(), + } + }(), + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := instance.Client.ActionV2.RemovePublicKey(tt.ctx, tt.req) + if tt.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + }) + } +} + +func assertDateWithinRangeClockSkew(t assert.TestingT, actual time.Time, start, end time.Time) { + assert.WithinRange(t, actual, start.Add(-time.Second), end.Add(time.Second)) +} diff --git a/internal/api/grpc/action/v2/query.go b/internal/api/grpc/action/v2/query.go index 1b09984ed..6d247179b 100644 --- a/internal/api/grpc/action/v2/query.go +++ b/internal/api/grpc/action/v2/query.go @@ -15,6 +15,7 @@ import ( "github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/internal/zerrors" "github.com/zitadel/zitadel/pkg/grpc/action/v2" + filter_pb "github.com/zitadel/zitadel/pkg/grpc/filter/v2" ) const ( @@ -58,6 +59,21 @@ func (s *Server) ListTargets(ctx context.Context, req *connect.Request[action.Li }), nil } +func (s *Server) ListPublicKeys(ctx context.Context, req *connect.Request[action.ListPublicKeysRequest]) (*connect.Response[action.ListPublicKeysResponse], error) { + queries, err := s.listPublicKeysRequestToQuery(req.Msg) + if err != nil { + return nil, err + } + res, err := s.query.SearchAuthNKeys(ctx, queries, query.JoinFilterTarget, nil) + if err != nil { + return nil, err + } + return connect.NewResponse(&action.ListPublicKeysResponse{ + Pagination: filter.QueryToPaginationPb(queries.SearchRequest, res.SearchResponse), + PublicKeys: publicKeysToPb(res.AuthNKeys), + }), nil +} + func (s *Server) ListExecutions(ctx context.Context, req *connect.Request[action.ListExecutionsRequest]) (*connect.Response[action.ListExecutionsResponse], error) { queries, err := s.ListExecutionsRequestToModel(req.Msg) if err != nil { @@ -83,11 +99,12 @@ func targetsToPb(targets []*query.Target) []*action.Target { func targetToPb(t *query.Target) *action.Target { target := &action.Target{ - Id: t.ID, - Name: t.Name, - Timeout: durationpb.New(t.Timeout), - Endpoint: t.Endpoint, - SigningKey: t.SigningKey, + Id: t.ID, + Name: t.Name, + Timeout: durationpb.New(t.Timeout), + Endpoint: t.Endpoint, + SigningKey: t.SigningKey, + PayloadType: payloadTypeToPb(t.PayloadType), } switch t.TargetType { case target_domain.TargetTypeWebhook: @@ -109,6 +126,21 @@ func targetToPb(t *query.Target) *action.Target { return target } +func payloadTypeToPb(payloadType target_domain.PayloadType) action.PayloadType { + switch payloadType { + case target_domain.PayloadTypeUnspecified: + return action.PayloadType_PAYLOAD_TYPE_UNSPECIFIED + case target_domain.PayloadTypeJSON: + return action.PayloadType_PAYLOAD_TYPE_JSON + case target_domain.PayloadTypeJWT: + return action.PayloadType_PAYLOAD_TYPE_JWT + case target_domain.PayloadTypeJWE: + return action.PayloadType_PAYLOAD_TYPE_JWE + default: + return action.PayloadType_PAYLOAD_TYPE_UNSPECIFIED + } +} + func (s *Server) ListTargetsRequestToModel(req *action.ListTargetsRequest) (*query.TargetSearchQueries, error) { offset, limit, asc, err := filter.PaginationPbToQuery(s.systemDefaults, req.Pagination) if err != nil { @@ -403,3 +435,98 @@ func includeEventToCondition(id string) *action.Condition { func includeFunctionToCondition(id string) *action.Condition { return &action.Condition{ConditionType: &action.Condition_Function{Function: &action.FunctionExecution{Name: strings.TrimPrefix(id, "/")}}} } + +func (s *Server) listPublicKeysRequestToQuery(req *action.ListPublicKeysRequest) (*query.AuthNKeySearchQueries, error) { + offset, limit, asc, err := filter.PaginationPbToQuery(s.systemDefaults, req.Pagination) + if err != nil { + return nil, err + } + queries, err := publicKeyFiltersToQuery(req.Filters, req.GetTargetId()) + if err != nil { + return nil, err + } + return &query.AuthNKeySearchQueries{ + SearchRequest: query.SearchRequest{ + Offset: offset, + Limit: limit, + Asc: asc, + SortingColumn: publicKeyFieldNameToSortingColumn(req.GetSortingColumn()), + }, + Queries: queries, + }, nil +} + +func publicKeyFiltersToQuery(filters []*action.PublicKeySearchFilter, targetID string) (_ []query.SearchQuery, err error) { + f := make([]query.SearchQuery, len(filters)+1) + for i, filter := range filters { + f[i], err = publicKeyFilterToQuery(filter) + if err != nil { + return nil, err + } + } + f[len(filters)], err = query.NewAuthNKeyIdentifyerQuery(targetID) + if err != nil { + return nil, err + } + return f, nil +} + +func publicKeyFilterToQuery(filter *action.PublicKeySearchFilter) (query.SearchQuery, error) { + switch q := filter.Filter.(type) { + case *action.PublicKeySearchFilter_KeyIdsFilter: + return publicKeyKeyIDsFilterToQuery(q.KeyIdsFilter) + case *action.PublicKeySearchFilter_ActiveFilter: + return publicKeyActiveFilterToQuery(q.ActiveFilter) + case *action.PublicKeySearchFilter_ExpirationDateFilter: + return publicKeyExpirationFilterToQuery(q.ExpirationDateFilter) + default: + return nil, zerrors.ThrowInvalidArgument(nil, "GRPC-vR9nC", "List.Query.Invalid") + } +} + +func publicKeyKeyIDsFilterToQuery(idsFilter *filter_pb.InIDsFilter) (query.SearchQuery, error) { + return query.NewAuthNKeyInIDsSearchQuery(idsFilter.GetIds()) +} + +func publicKeyActiveFilterToQuery(activeFilter bool) (query.SearchQuery, error) { + return query.NewAuthNKeyEnabledSearchQuery(activeFilter) +} + +func publicKeyExpirationFilterToQuery(f *filter_pb.TimestampFilter) (query.SearchQuery, error) { + return query.NewAuthNKeyExpirationDateQuery(f.GetTimestamp().AsTime(), filter.TimestampMethodPbToQuery(f.Method)) +} + +func publicKeyFieldNameToSortingColumn(column action.PublicKeyFieldName) query.Column { + switch column { + case action.PublicKeyFieldName_PUBLIC_KEY_FIELD_NAME_UNSPECIFIED: + return query.AuthNKeyColumnCreationDate + case action.PublicKeyFieldName_PUBLIC_KEY_FIELD_NAME_CREATION_DATE: + return query.AuthNKeyColumnCreationDate + case action.PublicKeyFieldName_PUBLIC_KEY_FIELD_NAME_CHANGE_DATE: + return query.AuthNKeyColumnChangeDate + case action.PublicKeyFieldName_PUBLIC_KEY_FIELD_NAME_EXPIRATION_DATE: + return query.AuthNKeyColumnExpiration + default: + return query.AuthNKeyColumnCreationDate + } +} + +func publicKeysToPb(keys []*query.AuthNKey) []*action.PublicKey { + pks := make([]*action.PublicKey, len(keys)) + for i, key := range keys { + var expiration *timestamppb.Timestamp + if !key.Expiration.IsZero() { + expiration = timestamppb.New(key.Expiration) + } + pks[i] = &action.PublicKey{ + KeyId: key.ID, + Active: key.Enabled, + PublicKey: key.PublicKey, + Fingerprint: key.Fingerprint, + ExpirationDate: expiration, + CreationDate: timestamppb.New(key.CreationDate), + ChangeDate: timestamppb.New(key.ChangeDate), + } + } + return pks +} diff --git a/internal/api/grpc/action/v2/target.go b/internal/api/grpc/action/v2/target.go index 28cb59b12..48d390297 100644 --- a/internal/api/grpc/action/v2/target.go +++ b/internal/api/grpc/action/v2/target.go @@ -2,6 +2,8 @@ package action import ( "context" + "strings" + "time" "connectrpc.com/connect" "github.com/muhlemmer/gu" @@ -64,6 +66,60 @@ func (s *Server) DeleteTarget(ctx context.Context, req *connect.Request[action.D }), nil } +func (s *Server) AddPublicKey(ctx context.Context, req *connect.Request[action.AddPublicKeyRequest]) (*connect.Response[action.AddPublicKeyResponse], error) { + instanceID := authz.GetInstance(ctx).InstanceID() + var expirationDate time.Time + if req.Msg.GetExpirationDate() != nil { + expirationDate = req.Msg.GetExpirationDate().AsTime() + } + key := &command.TargetPublicKey{ + TargetID: strings.TrimSpace(req.Msg.GetTargetId()), + PublicKey: req.Msg.GetPublicKey(), + Expiration: expirationDate, + } + creationDate, err := s.command.AddTargetPublicKey(ctx, key, instanceID) + if err != nil { + return nil, err + } + return connect.NewResponse(&action.AddPublicKeyResponse{ + KeyId: key.KeyID, + CreationDate: timestamppb.New(creationDate), + }), nil +} + +func (s *Server) ActivatePublicKey(ctx context.Context, req *connect.Request[action.ActivatePublicKeyRequest]) (*connect.Response[action.ActivatePublicKeyResponse], error) { + instanceID := authz.GetInstance(ctx).InstanceID() + changeDate, err := s.command.ActivateTargetPublicKey(ctx, strings.TrimSpace(req.Msg.GetTargetId()), strings.TrimSpace(req.Msg.GetKeyId()), instanceID) + if err != nil { + return nil, err + } + return connect.NewResponse(&action.ActivatePublicKeyResponse{ + ChangeDate: timestamppb.New(changeDate), + }), nil +} + +func (s *Server) DeactivatePublicKey(ctx context.Context, req *connect.Request[action.DeactivatePublicKeyRequest]) (*connect.Response[action.DeactivatePublicKeyResponse], error) { + instanceID := authz.GetInstance(ctx).InstanceID() + changeDate, err := s.command.DeactivateTargetPublicKey(ctx, strings.TrimSpace(req.Msg.GetTargetId()), strings.TrimSpace(req.Msg.GetKeyId()), instanceID) + if err != nil { + return nil, err + } + return connect.NewResponse(&action.DeactivatePublicKeyResponse{ + ChangeDate: timestamppb.New(changeDate), + }), nil +} + +func (s *Server) RemovePublicKey(ctx context.Context, req *connect.Request[action.RemovePublicKeyRequest]) (*connect.Response[action.RemovePublicKeyResponse], error) { + instanceID := authz.GetInstance(ctx).InstanceID() + deletionDate, err := s.command.RemoveTargetPublicKey(ctx, strings.TrimSpace(req.Msg.GetTargetId()), strings.TrimSpace(req.Msg.GetKeyId()), instanceID) + if err != nil { + return nil, err + } + return connect.NewResponse(&action.RemovePublicKeyResponse{ + DeletionDate: timestamppb.New(deletionDate), + }), nil +} + func createTargetToCommand(req *action.CreateTargetRequest) *command.AddTarget { var ( targetType target_domain.TargetType @@ -85,6 +141,22 @@ func createTargetToCommand(req *action.CreateTargetRequest) *command.AddTarget { Endpoint: req.GetEndpoint(), Timeout: req.GetTimeout().AsDuration(), InterruptOnError: interruptOnError, + PayloadType: payloadTypeToDomain(req.GetPayloadType()), + } +} + +func payloadTypeToDomain(payloadType action.PayloadType) target_domain.PayloadType { + switch payloadType { + case action.PayloadType_PAYLOAD_TYPE_UNSPECIFIED: + return target_domain.PayloadTypeUnspecified + case action.PayloadType_PAYLOAD_TYPE_JSON: + return target_domain.PayloadTypeJSON + case action.PayloadType_PAYLOAD_TYPE_JWT: + return target_domain.PayloadTypeJWT + case action.PayloadType_PAYLOAD_TYPE_JWE: + return target_domain.PayloadTypeJWE + default: + return target_domain.PayloadTypeUnspecified } } @@ -102,6 +174,7 @@ func updateTargetToCommand(req *action.UpdateTargetRequest) *command.ChangeTarge Name: req.Name, Endpoint: req.Endpoint, ExpirationSigningKey: expirationSigningKey, + PayloadType: payloadTypeToDomain(req.GetPayloadType()), } if req.TargetType != nil { switch t := req.GetTargetType().(type) { diff --git a/internal/api/grpc/action/v2/target_test.go b/internal/api/grpc/action/v2/target_test.go index 6a7c04cb4..9630df7d8 100644 --- a/internal/api/grpc/action/v2/target_test.go +++ b/internal/api/grpc/action/v2/target_test.go @@ -30,6 +30,7 @@ func Test_createTargetToCommand(t *testing.T) { Endpoint: "", Timeout: 0, InterruptOnError: false, + PayloadType: target_domain.PayloadTypeUnspecified, }, }, { @@ -40,7 +41,8 @@ func Test_createTargetToCommand(t *testing.T) { TargetType: &action.CreateTargetRequest_RestWebhook{ RestWebhook: &action.RESTWebhook{}, }, - Timeout: durationpb.New(10 * time.Second), + Timeout: durationpb.New(10 * time.Second), + PayloadType: action.PayloadType_PAYLOAD_TYPE_JSON, }}, want: &command.AddTarget{ Name: "target 1", @@ -48,6 +50,7 @@ func Test_createTargetToCommand(t *testing.T) { Endpoint: "https://example.com/hooks/1", Timeout: 10 * time.Second, InterruptOnError: false, + PayloadType: target_domain.PayloadTypeJSON, }, }, { @@ -58,7 +61,8 @@ func Test_createTargetToCommand(t *testing.T) { TargetType: &action.CreateTargetRequest_RestAsync{ RestAsync: &action.RESTAsync{}, }, - Timeout: durationpb.New(10 * time.Second), + Timeout: durationpb.New(10 * time.Second), + PayloadType: action.PayloadType_PAYLOAD_TYPE_JWT, }}, want: &command.AddTarget{ Name: "target 1", @@ -66,6 +70,7 @@ func Test_createTargetToCommand(t *testing.T) { Endpoint: "https://example.com/hooks/1", Timeout: 10 * time.Second, InterruptOnError: false, + PayloadType: target_domain.PayloadTypeJWT, }, }, { @@ -78,7 +83,8 @@ func Test_createTargetToCommand(t *testing.T) { InterruptOnError: true, }, }, - Timeout: durationpb.New(10 * time.Second), + Timeout: durationpb.New(10 * time.Second), + PayloadType: action.PayloadType_PAYLOAD_TYPE_JWE, }}, want: &command.AddTarget{ Name: "target 1", @@ -86,6 +92,7 @@ func Test_createTargetToCommand(t *testing.T) { Endpoint: "https://example.com/hooks/1", Timeout: 10 * time.Second, InterruptOnError: true, + PayloadType: target_domain.PayloadTypeJWE, }, }, } diff --git a/internal/api/grpc/action/v2beta/integration_test/execution_target_test.go b/internal/api/grpc/action/v2beta/integration_test/execution_target_test.go index b19ee2c0a..d463263b4 100644 --- a/internal/api/grpc/action/v2beta/integration_test/execution_target_test.go +++ b/internal/api/grpc/action/v2beta/integration_test/execution_target_test.go @@ -73,7 +73,7 @@ func TestServer_ExecutionTarget(t *testing.T) { targetCreatedName := integration.TargetName() targetCreatedURL := "https://nonexistent" - targetCreated := instance.CreateTarget(ctx, t, targetCreatedName, targetCreatedURL, target_domain.TargetTypeCall, false) + targetCreated := instance.CreateTargetWithoutPayloadType(ctx, t, targetCreatedName, targetCreatedURL, target_domain.TargetTypeCall, false) // request received by target wantRequest := &middleware.ContextInfoRequest{ @@ -223,7 +223,7 @@ func TestServer_ExecutionTarget(t *testing.T) { targetCreatedName := integration.TargetName() targetCreatedURL := "https://nonexistent" - targetCreated := instance.CreateTarget(ctx, t, targetCreatedName, targetCreatedURL, target_domain.TargetTypeCall, false) + targetCreated := instance.CreateTargetWithoutPayloadType(ctx, t, targetCreatedName, targetCreatedURL, target_domain.TargetTypeCall, false) // GetTarget with used target request.Id = targetCreated.GetId() diff --git a/internal/api/grpc/action/v2beta/integration_test/execution_test.go b/internal/api/grpc/action/v2beta/integration_test/execution_test.go index 78f4f9006..18433c305 100644 --- a/internal/api/grpc/action/v2beta/integration_test/execution_test.go +++ b/internal/api/grpc/action/v2beta/integration_test/execution_test.go @@ -18,7 +18,7 @@ import ( func TestServer_SetExecution_Request(t *testing.T) { instance := integration.NewInstance(CTX) isolatedIAMOwnerCTX := instance.WithAuthorizationToken(CTX, integration.UserTypeIAMOwner) - targetResp := instance.CreateTarget(isolatedIAMOwnerCTX, t, "", "https://notexisting", target_domain.TargetTypeWebhook, false) + targetResp := instance.CreateTargetWithoutPayloadType(isolatedIAMOwnerCTX, t, "", "https://notexisting", target_domain.TargetTypeWebhook, false) tests := []struct { name string @@ -175,7 +175,7 @@ func assertSetExecutionResponse(t *testing.T, creationDate, setDate time.Time, e func TestServer_SetExecution_Response(t *testing.T) { instance := integration.NewInstance(CTX) isolatedIAMOwnerCTX := instance.WithAuthorizationToken(CTX, integration.UserTypeIAMOwner) - targetResp := instance.CreateTarget(isolatedIAMOwnerCTX, t, "", "https://notexisting", target_domain.TargetTypeWebhook, false) + targetResp := instance.CreateTargetWithoutPayloadType(isolatedIAMOwnerCTX, t, "", "https://notexisting", target_domain.TargetTypeWebhook, false) tests := []struct { name string @@ -319,7 +319,7 @@ func TestServer_SetExecution_Response(t *testing.T) { func TestServer_SetExecution_Event(t *testing.T) { instance := integration.NewInstance(CTX) isolatedIAMOwnerCTX := instance.WithAuthorizationToken(CTX, integration.UserTypeIAMOwner) - targetResp := instance.CreateTarget(isolatedIAMOwnerCTX, t, "", "https://notexisting", target_domain.TargetTypeWebhook, false) + targetResp := instance.CreateTargetWithoutPayloadType(isolatedIAMOwnerCTX, t, "", "https://notexisting", target_domain.TargetTypeWebhook, false) tests := []struct { name string @@ -482,7 +482,7 @@ func TestServer_SetExecution_Event(t *testing.T) { func TestServer_SetExecution_Function(t *testing.T) { instance := integration.NewInstance(CTX) isolatedIAMOwnerCTX := instance.WithAuthorizationToken(CTX, integration.UserTypeIAMOwner) - targetResp := instance.CreateTarget(isolatedIAMOwnerCTX, t, "", "https://notexisting", target_domain.TargetTypeWebhook, false) + targetResp := instance.CreateTargetWithoutPayloadType(isolatedIAMOwnerCTX, t, "", "https://notexisting", target_domain.TargetTypeWebhook, false) tests := []struct { name string diff --git a/internal/api/grpc/action/v2beta/integration_test/query_test.go b/internal/api/grpc/action/v2beta/integration_test/query_test.go index d29fc1f53..1d2da41c5 100644 --- a/internal/api/grpc/action/v2beta/integration_test/query_test.go +++ b/internal/api/grpc/action/v2beta/integration_test/query_test.go @@ -54,7 +54,7 @@ func TestServer_GetTarget(t *testing.T) { ctx: isolatedIAMOwnerCTX, dep: func(ctx context.Context, request *action.GetTargetRequest, response *action.GetTargetResponse) error { name := integration.TargetName() - resp := instance.CreateTarget(ctx, t, name, "https://example.com", target_domain.TargetTypeWebhook, false) + resp := instance.CreateTargetWithoutPayloadType(ctx, t, name, "https://example.com", target_domain.TargetTypeWebhook, false) request.Id = resp.GetId() response.Target.Id = resp.GetId() response.Target.Name = name @@ -81,7 +81,7 @@ func TestServer_GetTarget(t *testing.T) { ctx: isolatedIAMOwnerCTX, dep: func(ctx context.Context, request *action.GetTargetRequest, response *action.GetTargetResponse) error { name := integration.TargetName() - resp := instance.CreateTarget(ctx, t, name, "https://example.com", target_domain.TargetTypeAsync, false) + resp := instance.CreateTargetWithoutPayloadType(ctx, t, name, "https://example.com", target_domain.TargetTypeAsync, false) request.Id = resp.GetId() response.Target.Id = resp.GetId() response.Target.Name = name @@ -108,7 +108,7 @@ func TestServer_GetTarget(t *testing.T) { ctx: isolatedIAMOwnerCTX, dep: func(ctx context.Context, request *action.GetTargetRequest, response *action.GetTargetResponse) error { name := integration.TargetName() - resp := instance.CreateTarget(ctx, t, name, "https://example.com", target_domain.TargetTypeWebhook, true) + resp := instance.CreateTargetWithoutPayloadType(ctx, t, name, "https://example.com", target_domain.TargetTypeWebhook, true) request.Id = resp.GetId() response.Target.Id = resp.GetId() response.Target.Name = name @@ -137,7 +137,7 @@ func TestServer_GetTarget(t *testing.T) { ctx: isolatedIAMOwnerCTX, dep: func(ctx context.Context, request *action.GetTargetRequest, response *action.GetTargetResponse) error { name := integration.TargetName() - resp := instance.CreateTarget(ctx, t, name, "https://example.com", target_domain.TargetTypeCall, false) + resp := instance.CreateTargetWithoutPayloadType(ctx, t, name, "https://example.com", target_domain.TargetTypeCall, false) request.Id = resp.GetId() response.Target.Id = resp.GetId() response.Target.Name = name @@ -166,7 +166,7 @@ func TestServer_GetTarget(t *testing.T) { ctx: isolatedIAMOwnerCTX, dep: func(ctx context.Context, request *action.GetTargetRequest, response *action.GetTargetResponse) error { name := integration.TargetName() - resp := instance.CreateTarget(ctx, t, name, "https://example.com", target_domain.TargetTypeCall, true) + resp := instance.CreateTargetWithoutPayloadType(ctx, t, name, "https://example.com", target_domain.TargetTypeCall, true) request.Id = resp.GetId() response.Target.Id = resp.GetId() response.Target.Name = name @@ -261,7 +261,7 @@ func TestServer_ListTargets(t *testing.T) { ctx: isolatedIAMOwnerCTX, dep: func(ctx context.Context, request *action.ListTargetsRequest, response *action.ListTargetsResponse) { name := integration.TargetName() - resp := instance.CreateTarget(ctx, t, name, "https://example.com", target_domain.TargetTypeWebhook, false) + resp := instance.CreateTargetWithoutPayloadType(ctx, t, name, "https://example.com", target_domain.TargetTypeWebhook, false) request.Filters[0].Filter = &action.TargetSearchFilter_InTargetIdsFilter{ InTargetIdsFilter: &action.InTargetIDsFilter{ TargetIds: []string{resp.GetId()}, @@ -301,7 +301,7 @@ func TestServer_ListTargets(t *testing.T) { ctx: isolatedIAMOwnerCTX, dep: func(ctx context.Context, request *action.ListTargetsRequest, response *action.ListTargetsResponse) { name := integration.TargetName() - resp := instance.CreateTarget(ctx, t, name, "https://example.com", target_domain.TargetTypeWebhook, false) + resp := instance.CreateTargetWithoutPayloadType(ctx, t, name, "https://example.com", target_domain.TargetTypeWebhook, false) request.Filters[0].Filter = &action.TargetSearchFilter_TargetNameFilter{ TargetNameFilter: &action.TargetNameFilter{ TargetName: name, @@ -344,9 +344,9 @@ func TestServer_ListTargets(t *testing.T) { name1 := integration.TargetName() name2 := integration.TargetName() name3 := integration.TargetName() - resp1 := instance.CreateTarget(ctx, t, name1, "https://example.com", target_domain.TargetTypeWebhook, false) - resp2 := instance.CreateTarget(ctx, t, name2, "https://example.com", target_domain.TargetTypeCall, true) - resp3 := instance.CreateTarget(ctx, t, name3, "https://example.com", target_domain.TargetTypeAsync, false) + resp1 := instance.CreateTargetWithoutPayloadType(ctx, t, name1, "https://example.com", target_domain.TargetTypeWebhook, false) + resp2 := instance.CreateTargetWithoutPayloadType(ctx, t, name2, "https://example.com", target_domain.TargetTypeCall, true) + resp3 := instance.CreateTargetWithoutPayloadType(ctx, t, name3, "https://example.com", target_domain.TargetTypeAsync, false) request.Filters[0].Filter = &action.TargetSearchFilter_InTargetIdsFilter{ InTargetIdsFilter: &action.InTargetIDsFilter{ TargetIds: []string{resp1.GetId(), resp2.GetId(), resp3.GetId()}, @@ -445,7 +445,7 @@ func assertPaginationResponse(t *assert.CollectT, expected *filter.PaginationRes func TestServer_ListExecutions(t *testing.T) { instance := integration.NewInstance(CTX) isolatedIAMOwnerCTX := instance.WithAuthorizationToken(CTX, integration.UserTypeIAMOwner) - targetResp := instance.CreateTarget(isolatedIAMOwnerCTX, t, "", "https://example.com", target_domain.TargetTypeWebhook, false) + targetResp := instance.CreateTargetWithoutPayloadType(isolatedIAMOwnerCTX, t, "", "https://example.com", target_domain.TargetTypeWebhook, false) type args struct { ctx context.Context @@ -523,7 +523,7 @@ func TestServer_ListExecutions(t *testing.T) { args: args{ ctx: isolatedIAMOwnerCTX, dep: func(ctx context.Context, request *action.ListExecutionsRequest, response *action.ListExecutionsResponse) { - target := instance.CreateTarget(isolatedIAMOwnerCTX, t, "", "https://example.com", target_domain.TargetTypeWebhook, false) + target := instance.CreateTargetWithoutPayloadType(isolatedIAMOwnerCTX, t, "", "https://example.com", target_domain.TargetTypeWebhook, false) // add target as Filter to the request request.Filters[0] = &action.ExecutionSearchFilter{ Filter: &action.ExecutionSearchFilter_TargetFilter{ diff --git a/internal/api/grpc/action/v2beta/integration_test/target_test.go b/internal/api/grpc/action/v2beta/integration_test/target_test.go index faf91f404..cae7e4edb 100644 --- a/internal/api/grpc/action/v2beta/integration_test/target_test.go +++ b/internal/api/grpc/action/v2beta/integration_test/target_test.go @@ -25,7 +25,7 @@ func TestServer_CreateTarget(t *testing.T) { signingKey bool } alreadyExistingTargetName := integration.TargetName() - instance.CreateTarget(isolatedIAMOwnerCTX, t, alreadyExistingTargetName, "https://example.com", target_domain.TargetTypeAsync, false) + instance.CreateTargetWithoutPayloadType(isolatedIAMOwnerCTX, t, alreadyExistingTargetName, "https://example.com", target_domain.TargetTypeAsync, false) tests := []struct { name string ctx context.Context @@ -262,7 +262,7 @@ func TestServer_UpdateTarget(t *testing.T) { { name: "missing permission", prepare: func(request *action.UpdateTargetRequest) { - targetID := instance.CreateTarget(isolatedIAMOwnerCTX, t, "", "https://example.com", target_domain.TargetTypeWebhook, false).GetId() + targetID := instance.CreateTargetWithoutPayloadType(isolatedIAMOwnerCTX, t, "", "https://example.com", target_domain.TargetTypeWebhook, false).GetId() request.Id = targetID }, args: args{ @@ -289,7 +289,7 @@ func TestServer_UpdateTarget(t *testing.T) { { name: "no change, ok", prepare: func(request *action.UpdateTargetRequest) { - targetID := instance.CreateTarget(isolatedIAMOwnerCTX, t, "", "https://example.com", target_domain.TargetTypeWebhook, false).GetId() + targetID := instance.CreateTargetWithoutPayloadType(isolatedIAMOwnerCTX, t, "", "https://example.com", target_domain.TargetTypeWebhook, false).GetId() request.Id = targetID }, args: args{ @@ -307,7 +307,7 @@ func TestServer_UpdateTarget(t *testing.T) { { name: "change name, ok", prepare: func(request *action.UpdateTargetRequest) { - targetID := instance.CreateTarget(isolatedIAMOwnerCTX, t, "", "https://example.com", target_domain.TargetTypeWebhook, false).GetId() + targetID := instance.CreateTargetWithoutPayloadType(isolatedIAMOwnerCTX, t, "", "https://example.com", target_domain.TargetTypeWebhook, false).GetId() request.Id = targetID }, args: args{ @@ -325,7 +325,7 @@ func TestServer_UpdateTarget(t *testing.T) { { name: "regenerate signingkey, ok", prepare: func(request *action.UpdateTargetRequest) { - targetID := instance.CreateTarget(isolatedIAMOwnerCTX, t, "", "https://example.com", target_domain.TargetTypeWebhook, false).GetId() + targetID := instance.CreateTargetWithoutPayloadType(isolatedIAMOwnerCTX, t, "", "https://example.com", target_domain.TargetTypeWebhook, false).GetId() request.Id = targetID }, args: args{ @@ -343,7 +343,7 @@ func TestServer_UpdateTarget(t *testing.T) { { name: "change type, ok", prepare: func(request *action.UpdateTargetRequest) { - targetID := instance.CreateTarget(isolatedIAMOwnerCTX, t, "", "https://example.com", target_domain.TargetTypeWebhook, false).GetId() + targetID := instance.CreateTargetWithoutPayloadType(isolatedIAMOwnerCTX, t, "", "https://example.com", target_domain.TargetTypeWebhook, false).GetId() request.Id = targetID }, args: args{ @@ -365,7 +365,7 @@ func TestServer_UpdateTarget(t *testing.T) { { name: "change url, ok", prepare: func(request *action.UpdateTargetRequest) { - targetID := instance.CreateTarget(isolatedIAMOwnerCTX, t, "", "https://example.com", target_domain.TargetTypeWebhook, false).GetId() + targetID := instance.CreateTargetWithoutPayloadType(isolatedIAMOwnerCTX, t, "", "https://example.com", target_domain.TargetTypeWebhook, false).GetId() request.Id = targetID }, args: args{ @@ -383,7 +383,7 @@ func TestServer_UpdateTarget(t *testing.T) { { name: "change timeout, ok", prepare: func(request *action.UpdateTargetRequest) { - targetID := instance.CreateTarget(isolatedIAMOwnerCTX, t, "", "https://example.com", target_domain.TargetTypeWebhook, false).GetId() + targetID := instance.CreateTargetWithoutPayloadType(isolatedIAMOwnerCTX, t, "", "https://example.com", target_domain.TargetTypeWebhook, false).GetId() request.Id = targetID }, args: args{ @@ -401,7 +401,7 @@ func TestServer_UpdateTarget(t *testing.T) { { name: "change type async, ok", prepare: func(request *action.UpdateTargetRequest) { - targetID := instance.CreateTarget(isolatedIAMOwnerCTX, t, "", "https://example.com", target_domain.TargetTypeAsync, false).GetId() + targetID := instance.CreateTargetWithoutPayloadType(isolatedIAMOwnerCTX, t, "", "https://example.com", target_domain.TargetTypeAsync, false).GetId() request.Id = targetID }, args: args{ @@ -497,7 +497,7 @@ func TestServer_DeleteTarget(t *testing.T) { ctx: iamOwnerCtx, prepare: func(request *action.DeleteTargetRequest) (time.Time, time.Time) { creationDate := time.Now().UTC() - targetID := instance.CreateTarget(iamOwnerCtx, t, "", "https://example.com", target_domain.TargetTypeWebhook, false).GetId() + targetID := instance.CreateTargetWithoutPayloadType(iamOwnerCtx, t, "", "https://example.com", target_domain.TargetTypeWebhook, false).GetId() request.Id = targetID return creationDate, time.Time{} }, @@ -509,7 +509,7 @@ func TestServer_DeleteTarget(t *testing.T) { ctx: iamOwnerCtx, prepare: func(request *action.DeleteTargetRequest) (time.Time, time.Time) { creationDate := time.Now().UTC() - targetID := instance.CreateTarget(iamOwnerCtx, t, "", "https://example.com", target_domain.TargetTypeWebhook, false).GetId() + targetID := instance.CreateTargetWithoutPayloadType(iamOwnerCtx, t, "", "https://example.com", target_domain.TargetTypeWebhook, false).GetId() request.Id = targetID instance.DeleteTarget(iamOwnerCtx, t, targetID) return creationDate, time.Now().UTC() diff --git a/internal/api/grpc/server/connect_middleware/execution_interceptor.go b/internal/api/grpc/server/connect_middleware/execution_interceptor.go index e144e8a04..111ac14fb 100644 --- a/internal/api/grpc/server/connect_middleware/execution_interceptor.go +++ b/internal/api/grpc/server/connect_middleware/execution_interceptor.go @@ -26,12 +26,12 @@ var headersToForward = map[string]bool{ strings.ToLower(http_utils.Origin): true, } -func ExecutionHandler(alg crypto.EncryptionAlgorithm) connect.UnaryInterceptorFunc { +func ExecutionHandler(alg crypto.EncryptionAlgorithm, activeSigningKey execution.GetActiveSigningWebKey) connect.UnaryInterceptorFunc { return func(handler connect.UnaryFunc) connect.UnaryFunc { return func(ctx context.Context, req connect.AnyRequest) (_ connect.AnyResponse, err error) { requestTargets := execution.QueryExecutionTargetsForRequest(ctx, req.Spec().Procedure) - handledReq, err := executeTargetsForRequest(ctx, requestTargets, req.Spec().Procedure, req, alg) + handledReq, err := executeTargetsForRequest(ctx, requestTargets, req.Spec().Procedure, req, alg, activeSigningKey) if err != nil { return nil, err } @@ -42,12 +42,12 @@ func ExecutionHandler(alg crypto.EncryptionAlgorithm) connect.UnaryInterceptorFu } responseTargets := execution.QueryExecutionTargetsForResponse(ctx, req.Spec().Procedure) - return executeTargetsForResponse(ctx, responseTargets, req.Spec().Procedure, handledReq, response, alg) + return executeTargetsForResponse(ctx, responseTargets, req.Spec().Procedure, handledReq, response, alg, activeSigningKey) } } } -func executeTargetsForRequest(ctx context.Context, targets []target_domain.Target, fullMethod string, req connect.AnyRequest, alg crypto.EncryptionAlgorithm) (_ connect.AnyRequest, err error) { +func executeTargetsForRequest(ctx context.Context, targets []target_domain.Target, fullMethod string, req connect.AnyRequest, alg crypto.EncryptionAlgorithm, activeSigningKey execution.GetActiveSigningWebKey) (_ connect.AnyRequest, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() @@ -67,14 +67,14 @@ func executeTargetsForRequest(ctx context.Context, targets []target_domain.Targe Headers: SetRequestHeaders(req.Header()), } - _, err = execution.CallTargets(ctx, targets, info, alg) + _, err = execution.CallTargets(ctx, targets, info, alg, activeSigningKey) if err != nil { return nil, err } return req, nil } -func executeTargetsForResponse(ctx context.Context, targets []target_domain.Target, fullMethod string, req connect.AnyRequest, resp connect.AnyResponse, alg crypto.EncryptionAlgorithm) (_ connect.AnyResponse, err error) { +func executeTargetsForResponse(ctx context.Context, targets []target_domain.Target, fullMethod string, req connect.AnyRequest, resp connect.AnyResponse, alg crypto.EncryptionAlgorithm, activeSigningKey execution.GetActiveSigningWebKey) (_ connect.AnyResponse, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() @@ -95,7 +95,7 @@ func executeTargetsForResponse(ctx context.Context, targets []target_domain.Targ Headers: SetRequestHeaders(req.Header()), } - _, err = execution.CallTargets(ctx, targets, info, alg) + _, err = execution.CallTargets(ctx, targets, info, alg, activeSigningKey) if err != nil { return nil, err } diff --git a/internal/api/grpc/server/connect_middleware/execution_interceptor_test.go b/internal/api/grpc/server/connect_middleware/execution_interceptor_test.go index 0b7a97800..a11c3b433 100644 --- a/internal/api/grpc/server/connect_middleware/execution_interceptor_test.go +++ b/internal/api/grpc/server/connect_middleware/execution_interceptor_test.go @@ -1,25 +1,43 @@ package connect_middleware import ( + "bytes" "context" + "crypto/rand" + "crypto/rsa" "encoding/json" "io" "net/http" "net/http/httptest" - "reflect" "testing" "time" "connectrpc.com/connect" + "github.com/go-jose/go-jose/v4" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/structpb" + "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/execution" target_domain "github.com/zitadel/zitadel/internal/execution/target" ) +var ( + privateKey = func() *rsa.PrivateKey { + privateKey, _ := rsa.GenerateKey(rand.Reader, 2048) + return privateKey + }() + encryptionKey = func() []byte { + data, _ := crypto.PublicKeyToBytes(&privateKey.PublicKey) + return data + }() + encryptionKeyID = "encryption-key-id" + signingAlgorithm = jose.RS256 +) + func newMockContentRequest(content string) *connect.Request[structpb.Struct] { return connect.NewRequest(&structpb.Struct{ Fields: map[string]*structpb.Value{ @@ -57,18 +75,20 @@ func newMockContextInfoResponse(fullMethod, request, response string) *ContextIn func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) { type target struct { - reqBody execution.ContextInfo - sleep time.Duration - statusCode int - respBody connect.AnyResponse + reqBody execution.ContextInfo + sleep time.Duration + statusCode int + respBody connect.AnyResponse + requestVerification func(*testing.T) func([]byte, []byte) bool } type args struct { ctx context.Context - executionTargets []target_domain.Target - targets []target - fullMethod string - req connect.AnyRequest + executionTargets []target_domain.Target + targets []target + fullMethod string + req connect.AnyRequest + getActiveSigningWebKey execution.GetActiveSigningWebKey } type res struct { want interface{} @@ -139,10 +159,11 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) { }, targets: []target{ { - reqBody: newMockContextInfoRequest("/service/method", "content"), - respBody: newMockContentResponse("content1"), - sleep: 0, - statusCode: http.StatusBadRequest, + reqBody: newMockContextInfoRequest("/service/method", "content"), + respBody: newMockContentResponse("content1"), + sleep: 0, + statusCode: http.StatusBadRequest, + requestVerification: validateJSONPayload, }, }, req: newMockContentRequest("content"), @@ -168,10 +189,11 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) { targets: []target{ { - reqBody: newMockContextInfoRequest("/service/method", "content"), - respBody: newMockContentResponse("content1"), - sleep: 0, - statusCode: http.StatusBadRequest, + reqBody: newMockContextInfoRequest("/service/method", "content"), + respBody: newMockContentResponse("content1"), + sleep: 0, + statusCode: http.StatusBadRequest, + requestVerification: validateJSONPayload, }, }, req: newMockContentRequest("content"), @@ -196,10 +218,11 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) { }, targets: []target{ { - reqBody: newMockContextInfoRequest("/service/method", "content"), - respBody: newMockContentResponse("content1"), - sleep: 5 * time.Second, - statusCode: http.StatusOK, + reqBody: newMockContextInfoRequest("/service/method", "content"), + respBody: newMockContentResponse("content1"), + sleep: 5 * time.Second, + statusCode: http.StatusOK, + requestVerification: validateJSONPayload, }, }, req: newMockContentRequest("content"), @@ -223,7 +246,10 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) { }, }, targets: []target{ - {reqBody: newMockContextInfoRequest("/service/method", "wrong")}, + { + reqBody: newMockContextInfoRequest("/service/method", "wrong"), + requestVerification: validateJSONPayload, + }, }, req: newMockContentRequest("content"), }, @@ -247,10 +273,11 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) { }, targets: []target{ { - reqBody: newMockContextInfoRequest("/service/method", "content"), - respBody: newMockContentResponse("content1"), - sleep: 0, - statusCode: http.StatusOK, + reqBody: newMockContextInfoRequest("/service/method", "content"), + respBody: newMockContentResponse("content1"), + sleep: 0, + statusCode: http.StatusOK, + requestVerification: validateJSONPayload, }, }, req: newMockContentRequest("content"), @@ -274,10 +301,11 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) { }, targets: []target{ { - reqBody: newMockContextInfoRequest("/service/method", "content"), - respBody: newMockContentResponse("content1"), - sleep: 5 * time.Second, - statusCode: http.StatusOK, + reqBody: newMockContextInfoRequest("/service/method", "content"), + respBody: newMockContentResponse("content1"), + sleep: 5 * time.Second, + statusCode: http.StatusOK, + requestVerification: validateJSONPayload, }, }, req: newMockContentRequest("content"), @@ -301,10 +329,11 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) { }, targets: []target{ { - reqBody: newMockContextInfoRequest("/service/method", "content"), - respBody: newMockContentResponse("content1"), - sleep: 0, - statusCode: http.StatusOK, + reqBody: newMockContextInfoRequest("/service/method", "content"), + respBody: newMockContentResponse("content1"), + sleep: 0, + statusCode: http.StatusOK, + requestVerification: validateJSONPayload, }, }, req: newMockContentRequest("content"), @@ -329,9 +358,10 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) { }, targets: []target{ { - reqBody: newMockContextInfoRequest("/service/method", "content"), - sleep: 0, - statusCode: http.StatusInternalServerError, + reqBody: newMockContextInfoRequest("/service/method", "content"), + sleep: 0, + statusCode: http.StatusInternalServerError, + requestVerification: validateJSONPayload, }, }, req: newMockContentRequest("content"), @@ -356,10 +386,11 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) { }, targets: []target{ { - reqBody: newMockContextInfoRequest("/service/method", "content"), - respBody: newMockContentResponse("content1"), - sleep: 5 * time.Second, - statusCode: http.StatusOK, + reqBody: newMockContextInfoRequest("/service/method", "content"), + respBody: newMockContentResponse("content1"), + sleep: 5 * time.Second, + statusCode: http.StatusOK, + requestVerification: validateJSONPayload, }, }, req: newMockContentRequest("content"), @@ -384,10 +415,11 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) { }, targets: []target{ { - reqBody: newMockContextInfoRequest("/service/method", "content"), - respBody: newMockContentResponse("content1"), - sleep: 0, - statusCode: http.StatusOK, + reqBody: newMockContextInfoRequest("/service/method", "content"), + respBody: newMockContentResponse("content1"), + sleep: 0, + statusCode: http.StatusOK, + requestVerification: validateJSONPayload, }, }, req: newMockContentRequest("content"), @@ -427,22 +459,25 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) { targets: []target{ { - reqBody: newMockContextInfoRequest("/service/method", "content"), - respBody: newMockContentResponse("content1"), - sleep: 0, - statusCode: http.StatusOK, + reqBody: newMockContextInfoRequest("/service/method", "content"), + respBody: newMockContentResponse("content1"), + sleep: 0, + statusCode: http.StatusOK, + requestVerification: validateJSONPayload, }, { - reqBody: newMockContextInfoRequest("/service/method", "content1"), - respBody: newMockContentResponse("content2"), - sleep: 0, - statusCode: http.StatusBadRequest, + reqBody: newMockContextInfoRequest("/service/method", "content1"), + respBody: newMockContentResponse("content2"), + sleep: 0, + statusCode: http.StatusBadRequest, + requestVerification: validateJSONPayload, }, { - reqBody: newMockContextInfoRequest("/service/method", "content2"), - respBody: newMockContentResponse("content3"), - sleep: 0, - statusCode: http.StatusOK, + reqBody: newMockContextInfoRequest("/service/method", "content2"), + respBody: newMockContentResponse("content3"), + sleep: 0, + statusCode: http.StatusOK, + requestVerification: validateJSONPayload, }, }, req: newMockContentRequest("content"), @@ -481,22 +516,25 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) { }, targets: []target{ { - reqBody: newMockContextInfoRequest("/service/method", "content"), - respBody: newMockContentResponse("content1"), - sleep: 0, - statusCode: http.StatusOK, + reqBody: newMockContextInfoRequest("/service/method", "content"), + respBody: newMockContentResponse("content1"), + sleep: 0, + statusCode: http.StatusOK, + requestVerification: validateJSONPayload, }, { - reqBody: newMockContextInfoRequest("/service/method", "content1"), - respBody: newMockContentResponse("content2"), - sleep: 5 * time.Second, - statusCode: http.StatusBadRequest, + reqBody: newMockContextInfoRequest("/service/method", "content1"), + respBody: newMockContentResponse("content2"), + sleep: 5 * time.Second, + statusCode: http.StatusBadRequest, + requestVerification: validateJSONPayload, }, { - reqBody: newMockContextInfoRequest("/service/method", "content2"), - respBody: newMockContentResponse("content3"), - sleep: 5 * time.Second, - statusCode: http.StatusOK, + reqBody: newMockContextInfoRequest("/service/method", "content2"), + respBody: newMockContentResponse("content3"), + sleep: 5 * time.Second, + statusCode: http.StatusOK, + requestVerification: validateJSONPayload, }, }, req: newMockContentRequest("content"), @@ -505,6 +543,70 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) { wantErr: true, }, }, + { + "payload JWT, ok", + args{ + ctx: context.Background(), + fullMethod: "/service/method", + executionTargets: []target_domain.Target{ + { + ExecutionID: "request./zitadel.session.v2.SessionService/SetSession", + TargetID: "target", + TargetType: target_domain.TargetTypeWebhook, + Timeout: time.Minute, + InterruptOnError: true, + PayloadType: target_domain.PayloadTypeJWT, + }, + }, + targets: []target{ + { + reqBody: newMockContextInfoRequest("/service/method", "content"), + respBody: newMockContentResponse("content1"), + sleep: 0, + statusCode: http.StatusOK, + requestVerification: validateJWTPayload, + }, + }, + req: newMockContentRequest("content"), + getActiveSigningWebKey: mockGetActiveSigningWebKey(), + }, + res{ + want: newMockContentRequest("content"), + }, + }, + { + "payload JWE, ok", + args{ + ctx: context.Background(), + fullMethod: "/service/method", + executionTargets: []target_domain.Target{ + { + ExecutionID: "request./zitadel.session.v2.SessionService/SetSession", + TargetID: "target", + TargetType: target_domain.TargetTypeWebhook, + Timeout: time.Minute, + InterruptOnError: true, + PayloadType: target_domain.PayloadTypeJWE, + EncryptionKey: encryptionKey, + EncryptionKeyID: encryptionKeyID, + }, + }, + targets: []target{ + { + reqBody: newMockContextInfoRequest("/service/method", "content"), + respBody: newMockContentResponse("content1"), + sleep: 0, + statusCode: http.StatusOK, + requestVerification: validateJWEPayload, + }, + }, + req: newMockContentRequest("content"), + getActiveSigningWebKey: mockGetActiveSigningWebKey(), + }, + res{ + want: newMockContentRequest("content"), + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -515,6 +617,7 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) { target.sleep, target.statusCode, target.respBody, + target.requestVerification(t), ) tt.args.executionTargets[i].Endpoint = url @@ -527,6 +630,7 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) { tt.args.fullMethod, tt.args.req, nil, + tt.args.getActiveSigningWebKey, ) if tt.res.wantErr { @@ -548,6 +652,7 @@ func testServerCall( sleep time.Duration, statusCode int, respBody connect.AnyResponse, + requestVerification func(expected, sent []byte) bool, ) (string, func()) { handler := func(w http.ResponseWriter, r *http.Request) { data, err := json.Marshal(reqBody) @@ -562,7 +667,7 @@ func testServerCall( return } - if !reflect.DeepEqual(data, sentBody) { + if !requestVerification(data, sentBody) { http.Error(w, "error", http.StatusInternalServerError) return } @@ -591,6 +696,45 @@ func testServerCall( return server.URL, server.Close } +func mockGetActiveSigningWebKey() func(ctx context.Context) (*jose.JSONWebKey, error) { + return func(ctx context.Context) (*jose.JSONWebKey, error) { + return &jose.JSONWebKey{ + Key: privateKey, + Algorithm: string(signingAlgorithm), + Use: "sig", + }, nil + } +} + +func validateJSONPayload(t *testing.T) func(expected, sent []byte) bool { + return bytes.Equal +} + +func validateJWTPayload(t *testing.T) func(expected, sent []byte) bool { + return func(expected, sent []byte) bool { + jws, err := jose.ParseSigned(string(sent), []jose.SignatureAlgorithm{jose.RS256}) + require.NoError(t, err) + payload, err := jws.Verify(privateKey.Public()) + require.NoError(t, err) + return bytes.Equal(expected, payload) + } +} + +func validateJWEPayload(t *testing.T) func(expected, sent []byte) bool { + return func(expected, sent []byte) bool { + parsedJWE, err := jose.ParseEncrypted(string(sent), []jose.KeyAlgorithm{jose.RSA_OAEP_256, jose.ECDH_ES_A256KW}, []jose.ContentEncryption{jose.A256GCM}) + if err != nil { + return false + } + require.Equal(t, encryptionKeyID, parsedJWE.Header.KeyID) + require.Equal(t, "JWT", parsedJWE.Header.ExtraHeaders[jose.HeaderContentType].(string)) + + decryptedJWS, err := parsedJWE.Decrypt(privateKey) + require.NoError(t, err) + return validateJWTPayload(t)(expected, decryptedJWS) + } +} + func Test_executeTargetsForGRPCFullMethod_response(t *testing.T) { type target struct { reqBody execution.ContextInfo @@ -710,6 +854,7 @@ func Test_executeTargetsForGRPCFullMethod_response(t *testing.T) { target.sleep, target.statusCode, target.respBody, + validateJSONPayload(t), ) tt.args.executionTargets[i].Endpoint = url @@ -723,6 +868,7 @@ func Test_executeTargetsForGRPCFullMethod_response(t *testing.T) { tt.args.req, tt.args.resp, nil, + mockGetActiveSigningWebKey(), ) if tt.res.wantErr { diff --git a/internal/api/grpc/server/middleware/execution_interceptor.go b/internal/api/grpc/server/middleware/execution_interceptor.go index a8b7c21ca..265380406 100644 --- a/internal/api/grpc/server/middleware/execution_interceptor.go +++ b/internal/api/grpc/server/middleware/execution_interceptor.go @@ -18,11 +18,11 @@ import ( "github.com/zitadel/zitadel/internal/telemetry/tracing" ) -func ExecutionHandler(alg crypto.EncryptionAlgorithm) grpc.UnaryServerInterceptor { +func ExecutionHandler(alg crypto.EncryptionAlgorithm, activeSigningKey execution.GetActiveSigningWebKey) grpc.UnaryServerInterceptor { return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) { requestTargets := execution.QueryExecutionTargetsForRequest(ctx, info.FullMethod) // call targets otherwise return req - handledReq, err := executeTargetsForRequest(ctx, requestTargets, info.FullMethod, req, alg) + handledReq, err := executeTargetsForRequest(ctx, requestTargets, info.FullMethod, req, alg, activeSigningKey) if err != nil { return nil, err } @@ -33,11 +33,11 @@ func ExecutionHandler(alg crypto.EncryptionAlgorithm) grpc.UnaryServerIntercepto } responseTargets := execution.QueryExecutionTargetsForResponse(ctx, info.FullMethod) - return executeTargetsForResponse(ctx, responseTargets, info.FullMethod, handledReq, response, alg) + return executeTargetsForResponse(ctx, responseTargets, info.FullMethod, handledReq, response, alg, activeSigningKey) } } -func executeTargetsForRequest(ctx context.Context, targets []target_domain.Target, fullMethod string, req interface{}, alg crypto.EncryptionAlgorithm) (_ interface{}, err error) { +func executeTargetsForRequest(ctx context.Context, targets []target_domain.Target, fullMethod string, req interface{}, alg crypto.EncryptionAlgorithm, activeSigningKey execution.GetActiveSigningWebKey) (_ interface{}, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() @@ -58,10 +58,10 @@ func executeTargetsForRequest(ctx context.Context, targets []target_domain.Targe Headers: connect_middleware.SetRequestHeaders(md), } - return execution.CallTargets(ctx, targets, info, alg) + return execution.CallTargets(ctx, targets, info, alg, activeSigningKey) } -func executeTargetsForResponse(ctx context.Context, targets []target_domain.Target, fullMethod string, req, resp interface{}, alg crypto.EncryptionAlgorithm) (_ interface{}, err error) { +func executeTargetsForResponse(ctx context.Context, targets []target_domain.Target, fullMethod string, req, resp interface{}, alg crypto.EncryptionAlgorithm, activeSigningKey execution.GetActiveSigningWebKey) (_ interface{}, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() @@ -83,7 +83,7 @@ func executeTargetsForResponse(ctx context.Context, targets []target_domain.Targ Headers: connect_middleware.SetRequestHeaders(md), } - return execution.CallTargets(ctx, targets, info, alg) + return execution.CallTargets(ctx, targets, info, alg, activeSigningKey) } var _ execution.ContextInfo = &ContextInfoRequest{} diff --git a/internal/api/grpc/server/middleware/execution_interceptor_test.go b/internal/api/grpc/server/middleware/execution_interceptor_test.go index 63e4872ff..bd80d3714 100644 --- a/internal/api/grpc/server/middleware/execution_interceptor_test.go +++ b/internal/api/grpc/server/middleware/execution_interceptor_test.go @@ -1,24 +1,42 @@ package middleware import ( + "bytes" "context" + "crypto/rand" + "crypto/rsa" "encoding/json" "io" "net/http" "net/http/httptest" - "reflect" "testing" "time" + "github.com/go-jose/go-jose/v4" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/structpb" + "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/execution" target_domain "github.com/zitadel/zitadel/internal/execution/target" ) +var ( + privateKey = func() *rsa.PrivateKey { + privateKey, _ := rsa.GenerateKey(rand.Reader, 2048) + return privateKey + }() + encryptionKey = func() []byte { + data, _ := crypto.PublicKeyToBytes(&privateKey.PublicKey) + return data + }() + encryptionKeyID = "encryption-key-id" + signingAlgorithm = jose.RS256 +) + func newMockContentRequest(content string) proto.Message { return &structpb.Struct{ Fields: map[string]*structpb.Value{ @@ -46,18 +64,20 @@ func newMockContextInfoResponse(fullMethod, request, response string) *ContextIn func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) { type target struct { - reqBody execution.ContextInfo - sleep time.Duration - statusCode int - respBody interface{} + reqBody execution.ContextInfo + sleep time.Duration + statusCode int + respBody interface{} + requestVerification func(*testing.T) func([]byte, []byte) bool } type args struct { ctx context.Context - executionTargets []target_domain.Target - targets []target - fullMethod string - req interface{} + executionTargets []target_domain.Target + targets []target + fullMethod string + req interface{} + getActiveSigningWebKey execution.GetActiveSigningWebKey } type res struct { want interface{} @@ -128,10 +148,11 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) { }, targets: []target{ { - reqBody: newMockContextInfoRequest("/service/method", "content"), - respBody: newMockContentRequest("content1"), - sleep: 0, - statusCode: http.StatusBadRequest, + reqBody: newMockContextInfoRequest("/service/method", "content"), + respBody: newMockContentRequest("content1"), + sleep: 0, + statusCode: http.StatusBadRequest, + requestVerification: validateJSONPayload, }, }, req: newMockContentRequest("content"), @@ -157,10 +178,11 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) { targets: []target{ { - reqBody: newMockContextInfoRequest("/service/method", "content"), - respBody: newMockContentRequest("content1"), - sleep: 0, - statusCode: http.StatusBadRequest, + reqBody: newMockContextInfoRequest("/service/method", "content"), + respBody: newMockContentRequest("content1"), + sleep: 0, + statusCode: http.StatusBadRequest, + requestVerification: validateJSONPayload, }, }, req: newMockContentRequest("content"), @@ -185,10 +207,11 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) { }, targets: []target{ { - reqBody: newMockContextInfoRequest("/service/method", "content"), - respBody: newMockContentRequest("content1"), - sleep: 5 * time.Second, - statusCode: http.StatusOK, + reqBody: newMockContextInfoRequest("/service/method", "content"), + respBody: newMockContentRequest("content1"), + sleep: 5 * time.Second, + statusCode: http.StatusOK, + requestVerification: validateJSONPayload, }, }, req: newMockContentRequest("content"), @@ -212,7 +235,10 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) { }, }, targets: []target{ - {reqBody: newMockContextInfoRequest("/service/method", "wrong")}, + { + reqBody: newMockContextInfoRequest("/service/method", "wrong"), + requestVerification: validateJSONPayload, + }, }, req: newMockContentRequest("content"), }, @@ -236,10 +262,11 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) { }, targets: []target{ { - reqBody: newMockContextInfoRequest("/service/method", "content"), - respBody: newMockContentRequest("content1"), - sleep: 0, - statusCode: http.StatusOK, + reqBody: newMockContextInfoRequest("/service/method", "content"), + respBody: newMockContentRequest("content1"), + sleep: 0, + statusCode: http.StatusOK, + requestVerification: validateJSONPayload, }, }, req: newMockContentRequest("content"), @@ -263,10 +290,11 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) { }, targets: []target{ { - reqBody: newMockContextInfoRequest("/service/method", "content"), - respBody: newMockContentRequest("content1"), - sleep: 5 * time.Second, - statusCode: http.StatusOK, + reqBody: newMockContextInfoRequest("/service/method", "content"), + respBody: newMockContentRequest("content1"), + sleep: 5 * time.Second, + statusCode: http.StatusOK, + requestVerification: validateJSONPayload, }, }, req: newMockContentRequest("content"), @@ -290,10 +318,11 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) { }, targets: []target{ { - reqBody: newMockContextInfoRequest("/service/method", "content"), - respBody: newMockContentRequest("content1"), - sleep: 0, - statusCode: http.StatusOK, + reqBody: newMockContextInfoRequest("/service/method", "content"), + respBody: newMockContentRequest("content1"), + sleep: 0, + statusCode: http.StatusOK, + requestVerification: validateJSONPayload, }, }, req: newMockContentRequest("content"), @@ -318,9 +347,10 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) { }, targets: []target{ { - reqBody: newMockContextInfoRequest("/service/method", "content"), - sleep: 0, - statusCode: http.StatusInternalServerError, + reqBody: newMockContextInfoRequest("/service/method", "content"), + sleep: 0, + statusCode: http.StatusInternalServerError, + requestVerification: validateJSONPayload, }, }, req: newMockContentRequest("content"), @@ -345,10 +375,11 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) { }, targets: []target{ { - reqBody: newMockContextInfoRequest("/service/method", "content"), - respBody: newMockContentRequest("content1"), - sleep: 5 * time.Second, - statusCode: http.StatusOK, + reqBody: newMockContextInfoRequest("/service/method", "content"), + respBody: newMockContentRequest("content1"), + sleep: 5 * time.Second, + statusCode: http.StatusOK, + requestVerification: validateJSONPayload, }, }, req: newMockContentRequest("content"), @@ -373,10 +404,11 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) { }, targets: []target{ { - reqBody: newMockContextInfoRequest("/service/method", "content"), - respBody: newMockContentRequest("content1"), - sleep: 0, - statusCode: http.StatusOK, + reqBody: newMockContextInfoRequest("/service/method", "content"), + respBody: newMockContentRequest("content1"), + sleep: 0, + statusCode: http.StatusOK, + requestVerification: validateJSONPayload, }, }, req: newMockContentRequest("content"), @@ -416,22 +448,25 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) { targets: []target{ { - reqBody: newMockContextInfoRequest("/service/method", "content"), - respBody: newMockContentRequest("content1"), - sleep: 0, - statusCode: http.StatusOK, + reqBody: newMockContextInfoRequest("/service/method", "content"), + respBody: newMockContentRequest("content1"), + sleep: 0, + statusCode: http.StatusOK, + requestVerification: validateJSONPayload, }, { - reqBody: newMockContextInfoRequest("/service/method", "content1"), - respBody: newMockContentRequest("content2"), - sleep: 0, - statusCode: http.StatusBadRequest, + reqBody: newMockContextInfoRequest("/service/method", "content1"), + respBody: newMockContentRequest("content2"), + sleep: 0, + statusCode: http.StatusBadRequest, + requestVerification: validateJSONPayload, }, { - reqBody: newMockContextInfoRequest("/service/method", "content2"), - respBody: newMockContentRequest("content3"), - sleep: 0, - statusCode: http.StatusOK, + reqBody: newMockContextInfoRequest("/service/method", "content2"), + respBody: newMockContentRequest("content3"), + sleep: 0, + statusCode: http.StatusOK, + requestVerification: validateJSONPayload, }, }, req: newMockContentRequest("content"), @@ -470,22 +505,25 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) { }, targets: []target{ { - reqBody: newMockContextInfoRequest("/service/method", "content"), - respBody: newMockContentRequest("content1"), - sleep: 0, - statusCode: http.StatusOK, + reqBody: newMockContextInfoRequest("/service/method", "content"), + respBody: newMockContentRequest("content1"), + sleep: 0, + statusCode: http.StatusOK, + requestVerification: validateJSONPayload, }, { - reqBody: newMockContextInfoRequest("/service/method", "content1"), - respBody: newMockContentRequest("content2"), - sleep: 5 * time.Second, - statusCode: http.StatusBadRequest, + reqBody: newMockContextInfoRequest("/service/method", "content1"), + respBody: newMockContentRequest("content2"), + sleep: 5 * time.Second, + statusCode: http.StatusBadRequest, + requestVerification: validateJSONPayload, }, { - reqBody: newMockContextInfoRequest("/service/method", "content2"), - respBody: newMockContentRequest("content3"), - sleep: 5 * time.Second, - statusCode: http.StatusOK, + reqBody: newMockContextInfoRequest("/service/method", "content2"), + respBody: newMockContentRequest("content3"), + sleep: 5 * time.Second, + statusCode: http.StatusOK, + requestVerification: validateJSONPayload, }, }, req: newMockContentRequest("content"), @@ -494,6 +532,70 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) { wantErr: true, }, }, + { + "payload JWT, ok", + args{ + ctx: context.Background(), + fullMethod: "/service/method", + executionTargets: []target_domain.Target{ + { + ExecutionID: "request./zitadel.session.v2.SessionService/SetSession", + TargetID: "target", + TargetType: target_domain.TargetTypeWebhook, + Timeout: time.Minute, + InterruptOnError: true, + PayloadType: target_domain.PayloadTypeJWT, + }, + }, + targets: []target{ + { + reqBody: newMockContextInfoRequest("/service/method", "content"), + respBody: newMockContentRequest("content1"), + sleep: 0, + statusCode: http.StatusOK, + requestVerification: validateJWTPayload, + }, + }, + req: newMockContentRequest("content"), + getActiveSigningWebKey: mockGetActiveSigningWebKey(), + }, + res{ + want: newMockContentRequest("content"), + }, + }, + { + "payload JWE, ok", + args{ + ctx: context.Background(), + fullMethod: "/service/method", + executionTargets: []target_domain.Target{ + { + ExecutionID: "request./zitadel.session.v2.SessionService/SetSession", + TargetID: "target", + TargetType: target_domain.TargetTypeWebhook, + Timeout: time.Minute, + InterruptOnError: true, + PayloadType: target_domain.PayloadTypeJWE, + EncryptionKey: encryptionKey, + EncryptionKeyID: encryptionKeyID, + }, + }, + targets: []target{ + { + reqBody: newMockContextInfoRequest("/service/method", "content"), + respBody: newMockContentRequest("content1"), + sleep: 0, + statusCode: http.StatusOK, + requestVerification: validateJWEPayload, + }, + }, + req: newMockContentRequest("content"), + getActiveSigningWebKey: mockGetActiveSigningWebKey(), + }, + res{ + want: newMockContentRequest("content"), + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -504,6 +606,7 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) { target.sleep, target.statusCode, target.respBody, + target.requestVerification(t), ) tt.args.executionTargets[i].Endpoint = url @@ -516,6 +619,7 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) { tt.args.fullMethod, tt.args.req, nil, + tt.args.getActiveSigningWebKey, ) if tt.res.wantErr { @@ -537,6 +641,7 @@ func testServerCall( sleep time.Duration, statusCode int, respBody interface{}, + requestVerification func(expected, sent []byte) bool, ) (string, func()) { handler := func(w http.ResponseWriter, r *http.Request) { data, err := json.Marshal(reqBody) @@ -551,7 +656,7 @@ func testServerCall( return } - if !reflect.DeepEqual(data, sentBody) { + if !requestVerification(data, sentBody) { http.Error(w, "error", http.StatusInternalServerError) return } @@ -580,6 +685,45 @@ func testServerCall( return server.URL, server.Close } +func mockGetActiveSigningWebKey() func(ctx context.Context) (*jose.JSONWebKey, error) { + return func(ctx context.Context) (*jose.JSONWebKey, error) { + return &jose.JSONWebKey{ + Key: privateKey, + Algorithm: string(signingAlgorithm), + Use: "sig", + }, nil + } +} + +func validateJSONPayload(t *testing.T) func(expected, sent []byte) bool { + return bytes.Equal +} + +func validateJWTPayload(t *testing.T) func(expected, sent []byte) bool { + return func(expected, sent []byte) bool { + jws, err := jose.ParseSigned(string(sent), []jose.SignatureAlgorithm{jose.RS256}) + require.NoError(t, err) + payload, err := jws.Verify(privateKey.Public()) + require.NoError(t, err) + return bytes.Equal(expected, payload) + } +} + +func validateJWEPayload(t *testing.T) func(expected, sent []byte) bool { + return func(expected, sent []byte) bool { + parsedJWE, err := jose.ParseEncrypted(string(sent), []jose.KeyAlgorithm{jose.RSA_OAEP_256, jose.ECDH_ES_A256KW}, []jose.ContentEncryption{jose.A256GCM}) + if err != nil { + return false + } + require.Equal(t, encryptionKeyID, parsedJWE.Header.KeyID) + require.Equal(t, "JWT", parsedJWE.Header.ExtraHeaders[jose.HeaderContentType].(string)) + + decryptedJWS, err := parsedJWE.Decrypt(privateKey) + require.NoError(t, err) + return validateJWTPayload(t)(expected, decryptedJWS) + } +} + func Test_executeTargetsForGRPCFullMethod_response(t *testing.T) { type target struct { reqBody execution.ContextInfo @@ -699,6 +843,7 @@ func Test_executeTargetsForGRPCFullMethod_response(t *testing.T) { target.sleep, target.statusCode, target.respBody, + validateJSONPayload(t), ) tt.args.executionTargets[i].Endpoint = url @@ -712,6 +857,7 @@ func Test_executeTargetsForGRPCFullMethod_response(t *testing.T) { tt.args.req, tt.args.resp, nil, + mockGetActiveSigningWebKey(), ) if tt.res.wantErr { diff --git a/internal/api/grpc/server/server.go b/internal/api/grpc/server/server.go index 736e948ed..44ef26ca1 100644 --- a/internal/api/grpc/server/server.go +++ b/internal/api/grpc/server/server.go @@ -79,7 +79,7 @@ func CreateServer( middleware.AuthorizationInterceptor(verifier, systemAuthz, authConfig), middleware.TranslationHandler(), middleware.QuotaExhaustedInterceptor(accessSvc, system_pb.SystemService_ServiceDesc.ServiceName), - middleware.ExecutionHandler(targetEncAlg), + middleware.ExecutionHandler(targetEncAlg, queries.GetActiveSigningWebKey), middleware.ValidationHandler(), middleware.ServiceHandler(), middleware.ActivityInterceptor(), diff --git a/internal/api/grpc/user/v2/key_query.go b/internal/api/grpc/user/v2/key_query.go index e9466a791..ab5eac356 100644 --- a/internal/api/grpc/user/v2/key_query.go +++ b/internal/api/grpc/user/v2/key_query.go @@ -98,7 +98,7 @@ func authnKeyCreatedFilterToQuery(f *filter_pb.TimestampFilter) (query.SearchQue } func authnKeyExpirationFilterToQuery(f *filter_pb.TimestampFilter) (query.SearchQuery, error) { - return query.NewAuthNKeyExpirationDateDateQuery(f.Timestamp.AsTime(), filter.TimestampMethodPbToQuery(f.Method)) + return query.NewAuthNKeyExpirationDateQuery(f.Timestamp.AsTime(), filter.TimestampMethodPbToQuery(f.Method)) } // authnKeyFieldNameToSortingColumn defaults to the creation date because this ensures deterministic pagination diff --git a/internal/api/oidc/auth_request.go b/internal/api/oidc/auth_request.go index bfbcd29f7..1d949cb30 100644 --- a/internal/api/oidc/auth_request.go +++ b/internal/api/oidc/auth_request.go @@ -19,6 +19,7 @@ import ( "github.com/zitadel/zitadel/internal/api/authz" http_utils "github.com/zitadel/zitadel/internal/api/http" "github.com/zitadel/zitadel/internal/api/http/middleware" + "github.com/zitadel/zitadel/internal/api/oidc/sign" "github.com/zitadel/zitadel/internal/api/ui/login" "github.com/zitadel/zitadel/internal/auth/repository/eventsourcing/handler" "github.com/zitadel/zitadel/internal/command" @@ -300,7 +301,7 @@ func (o *OPStorage) TerminateSessionFromRequest(ctx context.Context, endSessionR } else { logoutURI = logoutURI.JoinPath(LogoutPath) } - signer, _, err := GetSignerOnce(o.query.GetActiveSigningWebKey)(ctx) + signer, _, err := sign.GetSignerOnce(o.query.GetActiveSigningWebKey)(ctx) if err != nil { return "", err } diff --git a/internal/api/oidc/sign/signer.go b/internal/api/oidc/sign/signer.go new file mode 100644 index 000000000..908f548ac --- /dev/null +++ b/internal/api/oidc/sign/signer.go @@ -0,0 +1,56 @@ +package sign + +import ( + "context" + "sync" + + "github.com/go-jose/go-jose/v4" + + "github.com/zitadel/zitadel/internal/telemetry/tracing" + "github.com/zitadel/zitadel/internal/zerrors" +) + +// SignerFunc is a getter function that allows add-hoc retrieval of the instance's signer. +type SignerFunc func(ctx context.Context) (jose.Signer, jose.SignatureAlgorithm, error) + +// GetSignerOnce returns a function which retrieves the instance's signer from the database once. +// Repeated calls of the returned function return the same results. +func GetSignerOnce( + getActiveSigningWebKey func(ctx context.Context) (*jose.JSONWebKey, error), +) SignerFunc { + var ( + once sync.Once + signer jose.Signer + signAlg jose.SignatureAlgorithm + err error + ) + return func(ctx context.Context) (jose.Signer, jose.SignatureAlgorithm, error) { + once.Do(func() { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + var webKey *jose.JSONWebKey + webKey, err = getActiveSigningWebKey(ctx) + if err != nil { + return + } + signer, signAlg, err = signerFromWebKey(webKey) + }) + return signer, signAlg, err + } +} + +func signerFromWebKey(signingKey *jose.JSONWebKey) (jose.Signer, jose.SignatureAlgorithm, error) { + signAlg := jose.SignatureAlgorithm(signingKey.Algorithm) + signer, err := jose.NewSigner( + jose.SigningKey{ + Algorithm: signAlg, + Key: signingKey, + }, + (&jose.SignerOptions{}).WithType("JWT"), + ) + if err != nil { + return nil, "", zerrors.ThrowInternal(err, "OIDC-oaF0s", "Errors.Internal") + } + return signer, signAlg, nil +} diff --git a/internal/api/oidc/token.go b/internal/api/oidc/token.go index d7a258259..f3222be05 100644 --- a/internal/api/oidc/token.go +++ b/internal/api/oidc/token.go @@ -4,18 +4,16 @@ import ( "context" "encoding/base64" "slices" - "sync" "time" - "github.com/go-jose/go-jose/v4" "github.com/zitadel/oidc/v3/pkg/crypto" "github.com/zitadel/oidc/v3/pkg/oidc" "github.com/zitadel/oidc/v3/pkg/op" + "github.com/zitadel/zitadel/internal/api/oidc/sign" "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/telemetry/tracing" - "github.com/zitadel/zitadel/internal/zerrors" ) /* @@ -59,53 +57,8 @@ func (s *Server) accessTokenResponseFromSession(ctx context.Context, client op.C return resp, err } -// SignerFunc is a getter function that allows add-hoc retrieval of the instance's signer. -type SignerFunc func(ctx context.Context) (jose.Signer, jose.SignatureAlgorithm, error) - -func (s *Server) getSignerOnce() SignerFunc { - return GetSignerOnce(s.query.GetActiveSigningWebKey) -} - -// GetSignerOnce returns a function which retrieves the instance's signer from the database once. -// Repeated calls of the returned function return the same results. -func GetSignerOnce( - getActiveSigningWebKey func(ctx context.Context) (*jose.JSONWebKey, error), -) SignerFunc { - var ( - once sync.Once - signer jose.Signer - signAlg jose.SignatureAlgorithm - err error - ) - return func(ctx context.Context) (jose.Signer, jose.SignatureAlgorithm, error) { - once.Do(func() { - ctx, span := tracing.NewSpan(ctx) - defer func() { span.EndWithError(err) }() - - var webKey *jose.JSONWebKey - webKey, err = getActiveSigningWebKey(ctx) - if err != nil { - return - } - signer, signAlg, err = signerFromWebKey(webKey) - }) - return signer, signAlg, err - } -} - -func signerFromWebKey(signingKey *jose.JSONWebKey) (jose.Signer, jose.SignatureAlgorithm, error) { - signAlg := jose.SignatureAlgorithm(signingKey.Algorithm) - signer, err := jose.NewSigner( - jose.SigningKey{ - Algorithm: signAlg, - Key: signingKey, - }, - (&jose.SignerOptions{}).WithType("JWT"), - ) - if err != nil { - return nil, "", zerrors.ThrowInternal(err, "OIDC-oaF0s", "Errors.Internal") - } - return signer, signAlg, nil +func (s *Server) getSignerOnce() sign.SignerFunc { + return sign.GetSignerOnce(s.query.GetActiveSigningWebKey) } // userInfoFunc is a getter function that allows add-hoc retrieval of a user. @@ -120,7 +73,7 @@ func (s *Server) getUserInfo(userID, projectID, clientID string, projectRoleAsse } } -func (*Server) createIDToken(ctx context.Context, client op.Client, getUserInfo userInfoFunc, roleAssertion bool, getSigningKey SignerFunc, sessionID, accessToken string, audience []string, authMethods []domain.UserAuthMethodType, authTime time.Time, nonce string, actor *domain.TokenActor) (idToken string, exp uint64, err error) { +func (*Server) createIDToken(ctx context.Context, client op.Client, getUserInfo userInfoFunc, roleAssertion bool, getSigningKey sign.SignerFunc, sessionID, accessToken string, audience []string, authMethods []domain.UserAuthMethodType, authTime time.Time, nonce string, actor *domain.TokenActor) (idToken string, exp uint64, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() @@ -164,7 +117,7 @@ func timeToOIDCExpiresIn(exp time.Time) uint64 { return uint64(time.Until(exp) / time.Second) } -func (s *Server) createJWT(ctx context.Context, client op.Client, session *command.OIDCSession, getUserInfo userInfoFunc, assertRoles bool, getSigner SignerFunc) (_ string, err error) { +func (s *Server) createJWT(ctx context.Context, client op.Client, session *command.OIDCSession, getUserInfo userInfoFunc, assertRoles bool, getSigner sign.SignerFunc) (_ string, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() diff --git a/internal/api/oidc/token_exchange.go b/internal/api/oidc/token_exchange.go index 8cb087e76..402510d91 100644 --- a/internal/api/oidc/token_exchange.go +++ b/internal/api/oidc/token_exchange.go @@ -10,6 +10,7 @@ import ( "golang.org/x/text/language" "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/api/oidc/sign" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/telemetry/tracing" "github.com/zitadel/zitadel/internal/zerrors" @@ -317,7 +318,7 @@ func (s *Server) createExchangeJWT( client *Client, getUserInfo userInfoFunc, roleAssertion bool, - getSigner SignerFunc, + getSigner sign.SignerFunc, userID, resourceOwner string, audience, diff --git a/internal/api/oidc/userinfo.go b/internal/api/oidc/userinfo.go index 1ebbac581..183b44a10 100644 --- a/internal/api/oidc/userinfo.go +++ b/internal/api/oidc/userinfo.go @@ -461,7 +461,7 @@ func (s *Server) userinfoFlows(ctx context.Context, qu *query.OIDCUserInfo, user UserGrants: qu.UserGrants, } - resp, err := execution.CallTargets(ctx, executionTargets, info, s.targetEncryptionAlgorithm) + resp, err := execution.CallTargets(ctx, executionTargets, info, s.targetEncryptionAlgorithm, s.query.GetActiveSigningWebKey) if err != nil { return err } diff --git a/internal/api/saml/storage.go b/internal/api/saml/storage.go index 94288142c..1c81b3d5c 100644 --- a/internal/api/saml/storage.go +++ b/internal/api/saml/storage.go @@ -401,7 +401,7 @@ func (p *Storage) getCustomAttributes(ctx context.Context, user *query.User, use UserGrants: userGrants.UserGrants, } - resp, err := execution.CallTargets(ctx, executionTargets, info, p.targetEncAlg) + resp, err := execution.CallTargets(ctx, executionTargets, info, p.targetEncAlg, p.query.GetActiveSigningWebKey) if err != nil { return nil, err } diff --git a/internal/command/action_v2_execution_test.go b/internal/command/action_v2_execution_test.go index 2bd05ee65..c10e8d41e 100644 --- a/internal/command/action_v2_execution_test.go +++ b/internal/command/action_v2_execution_test.go @@ -181,6 +181,7 @@ func TestCommands_SetExecutionRequest(t *testing.T) { KeyID: "id", Crypted: []byte("12345678"), }, + target_domain.PayloadTypeJSON, ), ), ), @@ -236,6 +237,7 @@ func TestCommands_SetExecutionRequest(t *testing.T) { KeyID: "id", Crypted: []byte("12345678"), }, + target_domain.PayloadTypeJSON, ), ), ), @@ -291,6 +293,7 @@ func TestCommands_SetExecutionRequest(t *testing.T) { KeyID: "id", Crypted: []byte("12345678"), }, + target_domain.PayloadTypeJSON, ), ), ), @@ -863,6 +866,7 @@ func TestCommands_SetExecutionResponse(t *testing.T) { KeyID: "id", Crypted: []byte("12345678"), }, + target_domain.PayloadTypeJSON, ), ), expectPushFailed( @@ -963,6 +967,7 @@ func TestCommands_SetExecutionResponse(t *testing.T) { KeyID: "id", Crypted: []byte("12345678"), }, + target_domain.PayloadTypeJSON, ), ), ), diff --git a/internal/command/action_v2_target.go b/internal/command/action_v2_target.go index 1c61203cc..5317c53c2 100644 --- a/internal/command/action_v2_target.go +++ b/internal/command/action_v2_target.go @@ -22,6 +22,7 @@ type AddTarget struct { Endpoint string Timeout time.Duration InterruptOnError bool + PayloadType target_domain.PayloadType SigningKey string } @@ -77,6 +78,7 @@ func (c *Commands) AddTarget(ctx context.Context, add *AddTarget, resourceOwner add.Timeout, add.InterruptOnError, code.Crypted, + add.PayloadType, )) if err != nil { return time.Time{}, err @@ -95,6 +97,7 @@ type ChangeTarget struct { Endpoint *string Timeout *time.Duration InterruptOnError *bool + PayloadType target_domain.PayloadType ExpirationSigningKey bool SigningKey *string @@ -153,6 +156,7 @@ func (c *Commands) ChangeTarget(ctx context.Context, change *ChangeTarget, resou change.Timeout, change.InterruptOnError, changedSigningKey, + change.PayloadType, ) if changedEvent == nil { return existing.WriteModel.ChangeDate, nil diff --git a/internal/command/action_v2_target_key.go b/internal/command/action_v2_target_key.go new file mode 100644 index 000000000..47b71c924 --- /dev/null +++ b/internal/command/action_v2_target_key.go @@ -0,0 +1,190 @@ +package command + +import ( + "context" + "crypto/ecdsa" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "fmt" + "time" + + "github.com/zitadel/zitadel/internal/repository/target" + "github.com/zitadel/zitadel/internal/telemetry/tracing" + "github.com/zitadel/zitadel/internal/zerrors" +) + +const ( + ErrTargetIDMissing = "ErrTargetIDMissing" + ErrPublicKeyFormat = "ErrPublicKeyFormat" + ErrExpirationDateBeforeNow = "ErrExpirationDateBeforeNow" + ErrPublicKeyDeleteActiveKey = "ErrPublicKeyDeleteActiveKey" + ErrTargetNotFound = "ErrTargetNotFound" + ErrKeyNotFound = "ErrKeyNotFound" + ErrPublicKeyExpired = "ErrPublicKeyExpired" +) + +type TargetPublicKey struct { + TargetID string + PublicKey []byte + Expiration time.Time + + // KeyID is generated and returned after adding the public key + KeyID string +} + +func (c *Commands) AddTargetPublicKey(ctx context.Context, key *TargetPublicKey, resourceOwner string) (_ time.Time, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + if key.TargetID == "" { + return time.Time{}, zerrors.ThrowInvalidArgument(nil, ErrTargetIDMissing, "Errors.IDMissing") + } + if !key.Expiration.IsZero() && key.Expiration.Before(time.Now()) { + return time.Time{}, zerrors.ThrowInvalidArgument(nil, ErrExpirationDateBeforeNow, "Errors.Target.InvalidExpirationDate") + } + fingerprint, err := checkPublicKeyAndComputeFingerprint(key.PublicKey) + if err != nil { + return time.Time{}, err + } + key.KeyID, err = c.idGenerator.Next() + if err != nil { + return time.Time{}, err + } + writeModel, err := c.getTargetKeyWriteModelByID(ctx, key.TargetID, key.KeyID, resourceOwner) + if err != nil { + return time.Time{}, err + } + if !writeModel.TargetExists { + return time.Time{}, zerrors.ThrowPreconditionFailed(nil, ErrTargetNotFound, "Errors.Target.NotFound") + } + err = c.pushAppendAndReduce(ctx, writeModel, target.NewKeyAddedEvent( + ctx, + TargetAggregateFromWriteModel(&writeModel.WriteModel), + key.KeyID, + key.PublicKey, + fingerprint, + key.Expiration, + )) + if err != nil { + return time.Time{}, err + } + return writeModel.ChangeDate, nil +} + +func (c *Commands) ActivateTargetPublicKey(ctx context.Context, targetID, keyID, resourceOwner string) (_ time.Time, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + if targetID == "" || keyID == "" { + return time.Time{}, zerrors.ThrowInvalidArgument(nil, ErrTargetIDMissing, "Errors.IDMissing") + } + writeModel, err := c.getTargetKeyWriteModelByID(ctx, targetID, keyID, resourceOwner) + if err != nil { + return time.Time{}, err + } + if !writeModel.TargetExists || !writeModel.KeyExists { + return time.Time{}, zerrors.ThrowPreconditionFailed(nil, ErrKeyNotFound, "Errors.Target.NotFound") + } + if !writeModel.ExpirationDate.IsZero() && writeModel.ExpirationDate.Before(time.Now()) { + return time.Time{}, zerrors.ThrowPreconditionFailed(nil, ErrPublicKeyExpired, "Errors.Target.PublicKeyExpired") + } + if writeModel.Active { + return writeModel.ChangeDate, nil + } + err = c.pushAppendAndReduce(ctx, writeModel, target.NewKeyActivatedEvent( + ctx, + TargetAggregateFromWriteModel(&writeModel.WriteModel), + keyID, + )) + if err != nil { + return time.Time{}, err + } + return writeModel.ChangeDate, nil +} + +func (c *Commands) DeactivateTargetPublicKey(ctx context.Context, targetID, keyID, resourceOwner string) (_ time.Time, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + if targetID == "" || keyID == "" { + return time.Time{}, zerrors.ThrowInvalidArgument(nil, ErrTargetIDMissing, "Errors.IDMissing") + } + writeModel, err := c.getTargetKeyWriteModelByID(ctx, targetID, keyID, resourceOwner) + if err != nil { + return time.Time{}, err + } + if !writeModel.TargetExists || !writeModel.KeyExists { + return time.Time{}, zerrors.ThrowPreconditionFailed(nil, ErrKeyNotFound, "Errors.Target.NotFound") + } + if !writeModel.Active { + return writeModel.ChangeDate, nil + } + err = c.pushAppendAndReduce(ctx, writeModel, target.NewKeyDeactivatedEvent( + ctx, + TargetAggregateFromWriteModel(&writeModel.WriteModel), + keyID, + )) + if err != nil { + return time.Time{}, err + } + return writeModel.ChangeDate, nil +} + +func (c *Commands) RemoveTargetPublicKey(ctx context.Context, targetID, keyID, resourceOwner string) (_ time.Time, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + if targetID == "" || keyID == "" { + return time.Time{}, zerrors.ThrowInvalidArgument(nil, ErrTargetIDMissing, "Errors.IDMissing") + } + writeModel, err := c.getTargetKeyWriteModelByID(ctx, targetID, keyID, resourceOwner) + if err != nil { + return time.Time{}, err + } + if !writeModel.TargetExists || !writeModel.KeyExists { + return writeModel.ChangeDate, nil + } + if writeModel.Active { + return time.Time{}, zerrors.ThrowPreconditionFailed(nil, ErrPublicKeyDeleteActiveKey, "Errors.Target.PublicKeyActive") + } + err = c.pushAppendAndReduce(ctx, writeModel, target.NewKeyRemovedEvent( + ctx, + TargetAggregateFromWriteModel(&writeModel.WriteModel), + keyID, + )) + if err != nil { + return time.Time{}, err + } + return writeModel.ChangeDate, nil +} + +func checkPublicKeyAndComputeFingerprint(key []byte) (string, error) { + block, _ := pem.Decode(key) + if block == nil { + return "", zerrors.ThrowInvalidArgument(nil, ErrPublicKeyFormat, "Errors.Target.InvalidPublicKey") + } + pub, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + return "", zerrors.ThrowInvalidArgument(err, ErrPublicKeyFormat, "Errors.Target.InvalidPublicKey") + } + switch pub.(type) { + case *rsa.PublicKey, + *ecdsa.PublicKey: + fingerprint := sha256.Sum256(block.Bytes) + return fmt.Sprintf("SHA256:%s", base64.RawStdEncoding.EncodeToString(fingerprint[:])), nil + default: + return "", zerrors.ThrowInvalidArgument(nil, ErrPublicKeyFormat, "Errors.Target.InvalidPublicKey") + } +} + +func (c *Commands) getTargetKeyWriteModelByID(ctx context.Context, targetID, keyID string, resourceOwner string) (*TargetKeyWriteModel, error) { + wm := NewTargetKeyWriteModel(targetID, keyID, resourceOwner) + err := c.eventstore.FilterToQueryReducer(ctx, wm) + if err != nil { + return nil, err + } + return wm, nil +} diff --git a/internal/command/action_v2_target_key_model.go b/internal/command/action_v2_target_key_model.go new file mode 100644 index 000000000..1fdc03ab4 --- /dev/null +++ b/internal/command/action_v2_target_key_model.go @@ -0,0 +1,94 @@ +package command + +import ( + "time" + + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/repository/target" +) + +type TargetKeyWriteModel struct { + eventstore.WriteModel + KeyID string + + KeyExists bool + TargetExists bool + Active bool + ExpirationDate time.Time +} + +func NewTargetKeyWriteModel(targetID, keyID string, resourceOwner string) *TargetKeyWriteModel { + return &TargetKeyWriteModel{ + WriteModel: eventstore.WriteModel{ + AggregateID: targetID, + ResourceOwner: resourceOwner, + }, + KeyID: keyID, + } +} + +func (wm *TargetKeyWriteModel) AppendEvents(events ...eventstore.Event) { + for _, event := range events { + switch e := event.(type) { + case *target.KeyAddedEvent: + if wm.KeyID != e.KeyID { + continue + } + wm.WriteModel.AppendEvents(e) + case *target.KeyActivatedEvent: + // don't check for KeyID, as only one key can be active at a time any activation event affects all keys + wm.WriteModel.AppendEvents(e) + case *target.KeyDeactivatedEvent: + if wm.KeyID != e.KeyID { + continue + } + wm.WriteModel.AppendEvents(e) + case *target.KeyRemovedEvent: + if wm.KeyID != e.KeyID { + continue + } + wm.WriteModel.AppendEvents(e) + case *target.AddedEvent: + wm.WriteModel.AppendEvents(e) + case *target.RemovedEvent: + wm.WriteModel.AppendEvents(e) + } + } +} + +func (wm *TargetKeyWriteModel) Reduce() error { + for _, event := range wm.Events { + switch e := event.(type) { + case *target.KeyAddedEvent: + wm.KeyID = e.KeyID + wm.KeyExists = true + wm.ExpirationDate = e.ExpirationDate + case *target.KeyActivatedEvent: + wm.Active = wm.KeyID == e.KeyID + case *target.KeyDeactivatedEvent: + wm.Active = false + case *target.KeyRemovedEvent: + wm.KeyExists = false + case *target.AddedEvent: + wm.TargetExists = true + case *target.RemovedEvent: + wm.TargetExists = false + } + } + return wm.WriteModel.Reduce() +} + +func (wm *TargetKeyWriteModel) Query() *eventstore.SearchQueryBuilder { + return eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent). + ResourceOwner(wm.ResourceOwner). + AddQuery(). + AggregateTypes(target.AggregateType). + AggregateIDs(wm.AggregateID). + EventTypes(target.KeyAddedEventType, + target.KeyActivatedEventType, + target.KeyDeactivatedEventType, + target.KeyRemovedEventType, + target.AddedEventType, + target.RemovedEventType). + Builder() +} diff --git a/internal/command/action_v2_target_key_test.go b/internal/command/action_v2_target_key_test.go new file mode 100644 index 000000000..9c83b9911 --- /dev/null +++ b/internal/command/action_v2_target_key_test.go @@ -0,0 +1,655 @@ +package command + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/zitadel/zitadel/internal/crypto" + "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/target" + "github.com/zitadel/zitadel/internal/zerrors" +) + +var ( + rsaPublicKey = func() *rsa.PublicKey { + privateKey, _ := rsa.GenerateKey(rand.Reader, 2048) + return &privateKey.PublicKey + }() + rsaPublicKeyBytes = func() []byte { + data, _ := crypto.PublicKeyToBytes(rsaPublicKey) + return data + }() + rsaFingerprint = func() string { + block, _ := x509.MarshalPKIXPublicKey(rsaPublicKey) + hash := sha256.Sum256(block) + return fmt.Sprintf("SHA256:%s", base64.RawStdEncoding.EncodeToString(hash[:])) + }() +) + +func TestCommands_AddTargetPublicKey(t *testing.T) { + type fields struct { + eventstore func(t *testing.T) *eventstore.Eventstore + idGenerator id.Generator + } + type args struct { + ctx context.Context + key *TargetPublicKey + resourceOwner string + } + type res struct { + err error + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + name: "missing target id", + fields: fields{ + eventstore: expectEventstore(), + }, + args: args{ + ctx: context.Background(), + key: &TargetPublicKey{}, + }, + res: res{ + err: zerrors.ThrowInvalidArgument(nil, ErrTargetIDMissing, "Errors.IDMissing"), + }, + }, + { + name: "expiration date before now", + fields: fields{ + eventstore: expectEventstore(), + }, + args: args{ + ctx: context.Background(), + key: &TargetPublicKey{ + TargetID: "target-1", + Expiration: testNow.Add(-time.Hour), + }, + }, + res: res{ + err: zerrors.ThrowInvalidArgument(nil, ErrExpirationDateBeforeNow, "Errors.Target.InvalidExpirationDate"), + }, + }, + { + name: "invalid public key", + fields: fields{ + eventstore: expectEventstore(), + }, + args: args{ + ctx: context.Background(), + key: &TargetPublicKey{ + TargetID: "target-1", + PublicKey: []byte("invalid-key"), + }, + }, + res: res{ + err: zerrors.ThrowInvalidArgument(nil, ErrPublicKeyFormat, "Errors.Target.InvalidPublicKey"), + }, + }, + { + name: "invalid public key format (PKCS1)", + fields: fields{ + eventstore: expectEventstore(), + }, + args: args{ + ctx: context.Background(), + key: &TargetPublicKey{ + TargetID: "target-1", + PublicKey: pem.EncodeToMemory(&pem.Block{Type: "RSA PUBLIC KEY", Bytes: x509.MarshalPKCS1PublicKey(rsaPublicKey)}), + }, + }, + res: res{ + err: zerrors.ThrowInvalidArgument(nil, ErrPublicKeyFormat, "Errors.Target.InvalidPublicKey"), + }, + }, + { + name: "invalid public key type (ed25519)", + fields: fields{ + eventstore: expectEventstore(), + }, + args: args{ + ctx: context.Background(), + key: &TargetPublicKey{ + TargetID: "target-1", + PublicKey: func() []byte { + publicKey, _, _ := ed25519.GenerateKey(rand.Reader) + ed25519PublicKeyBytes, _ := x509.MarshalPKIXPublicKey(publicKey) + return pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: ed25519PublicKeyBytes}) + }(), + }, + }, + res: res{ + err: zerrors.ThrowInvalidArgument(nil, ErrPublicKeyFormat, "Errors.Target.InvalidPublicKey"), + }, + }, + { + name: "target not found", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + ), + idGenerator: mock.ExpectID(t, "key-1"), + }, + args: args{ + ctx: context.Background(), + key: &TargetPublicKey{ + TargetID: "target-1", + PublicKey: rsaPublicKeyBytes, + }, + }, + res: res{ + err: zerrors.ThrowPreconditionFailed(nil, ErrTargetNotFound, "Errors.Target.NotFound"), + }, + }, + { + name: "success", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + targetAddEvent("target-1", "instance1"), + ), + ), + expectPush( + eventFromEventPusher( + target.NewKeyAddedEvent(context.Background(), + target.NewAggregate("target-1", "instance1"), + "key-1", + rsaPublicKeyBytes, + rsaFingerprint, + time.Time{}, + ), + ), + ), + ), + idGenerator: mock.ExpectID(t, "key-1"), + }, + args: args{ + ctx: context.Background(), + key: &TargetPublicKey{ + TargetID: "target-1", + PublicKey: rsaPublicKeyBytes, + }, + }, + res: res{}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore(t), + idGenerator: tt.fields.idGenerator, + } + _, err := c.AddTargetPublicKey(tt.args.ctx, tt.args.key, tt.args.resourceOwner) + assert.ErrorIs(t, err, tt.res.err) + }) + } +} + +func TestCommands_ActivateTargetPublicKey(t *testing.T) { + type fields struct { + eventstore func(t *testing.T) *eventstore.Eventstore + } + type args struct { + ctx context.Context + targetID string + keyID string + resourceOwner string + } + type res struct { + err error + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + name: "missing target id", + fields: fields{ + eventstore: expectEventstore(), + }, + args: args{ + ctx: context.Background(), + }, + res: res{ + err: zerrors.ThrowInvalidArgument(nil, ErrTargetIDMissing, "Errors.IDMissing"), + }, + }, + { + name: "missing key id", + fields: fields{ + eventstore: expectEventstore(), + }, + args: args{ + ctx: context.Background(), + targetID: "target-1", + }, + res: res{ + err: zerrors.ThrowInvalidArgument(nil, ErrTargetIDMissing, "Errors.IDMissing"), + }, + }, + { + name: "target not found", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + ), + }, + args: args{ + ctx: context.Background(), + targetID: "target-1", + keyID: "key-1", + }, + res: res{ + err: zerrors.ThrowPreconditionFailed(nil, ErrKeyNotFound, "Errors.Target.NotFound"), + }, + }, + { + name: "already expired", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + targetAddEvent("target-1", "instance1"), + ), + eventFromEventPusher( + target.NewKeyAddedEvent(context.Background(), + target.NewAggregate("target-1", "instance1"), + "key-1", + rsaPublicKeyBytes, + rsaFingerprint, + time.Now().Add(-time.Hour), + ), + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + targetID: "target-1", + keyID: "key-1", + }, + res: res{ + err: zerrors.ThrowPreconditionFailed(nil, ErrPublicKeyExpired, "Errors.Target.PublicKeyExpired"), + }, + }, + { + name: "already active", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + targetAddEvent("target-1", "instance1"), + ), + eventFromEventPusher( + target.NewKeyAddedEvent(context.Background(), + target.NewAggregate("target-1", "instance1"), + "key-1", + rsaPublicKeyBytes, + rsaFingerprint, + time.Time{}, + ), + ), + eventFromEventPusher( + target.NewKeyActivatedEvent(context.Background(), + target.NewAggregate("target-1", "instance1"), + "key-1", + ), + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + targetID: "target-1", + keyID: "key-1", + }, + res: res{}, + }, + { + name: "success", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + targetAddEvent("target-1", "instance1"), + ), + eventFromEventPusher( + target.NewKeyAddedEvent(context.Background(), + target.NewAggregate("target-1", "instance1"), + "key-1", + rsaPublicKeyBytes, + rsaFingerprint, + time.Time{}, + ), + ), + ), + expectPush( + eventFromEventPusher( + target.NewKeyActivatedEvent(context.Background(), + target.NewAggregate("target-1", "instance1"), + "key-1", + ), + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + targetID: "target-1", + keyID: "key-1", + }, + res: res{}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore(t), + } + _, err := c.ActivateTargetPublicKey(tt.args.ctx, tt.args.targetID, tt.args.keyID, tt.args.resourceOwner) + assert.ErrorIs(t, err, tt.res.err) + }) + } +} + +func TestCommands_DeactivateTargetPublicKey(t *testing.T) { + type fields struct { + eventstore func(t *testing.T) *eventstore.Eventstore + } + type args struct { + ctx context.Context + targetID string + keyID string + resourceOwner string + } + type res struct { + err error + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + name: "missing target id", + fields: fields{ + eventstore: expectEventstore(), + }, + args: args{ + ctx: context.Background(), + }, + res: res{ + err: zerrors.ThrowInvalidArgument(nil, ErrTargetIDMissing, "Errors.IDMissing"), + }, + }, + { + name: "missing key id", + fields: fields{ + eventstore: expectEventstore(), + }, + args: args{ + ctx: context.Background(), + targetID: "target-1", + }, + res: res{ + err: zerrors.ThrowInvalidArgument(nil, ErrTargetIDMissing, "Errors.IDMissing"), + }, + }, + { + name: "target not found", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + ), + }, + args: args{ + ctx: context.Background(), + targetID: "target-1", + keyID: "key-1", + }, + res: res{ + err: zerrors.ThrowPreconditionFailed(nil, ErrKeyNotFound, "Errors.Target.NotFound"), + }, + }, + { + name: "already inactive", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + targetAddEvent("target-1", "instance1"), + ), + eventFromEventPusher( + target.NewKeyAddedEvent(context.Background(), + target.NewAggregate("target-1", "instance1"), + "key-1", + rsaPublicKeyBytes, + rsaFingerprint, + time.Time{}, + ), + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + targetID: "target-1", + keyID: "key-1", + }, + res: res{}, + }, + { + name: "success", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + targetAddEvent("target-1", "instance1"), + ), + eventFromEventPusher( + target.NewKeyAddedEvent(context.Background(), + target.NewAggregate("target-1", "instance1"), + "key-1", + rsaPublicKeyBytes, + rsaFingerprint, + time.Time{}, + ), + ), + eventFromEventPusher( + target.NewKeyActivatedEvent(context.Background(), + target.NewAggregate("target-1", "instance1"), + "key-1", + ), + ), + ), + expectPush( + eventFromEventPusher( + target.NewKeyDeactivatedEvent(context.Background(), + target.NewAggregate("target-1", "instance1"), + "key-1", + ), + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + targetID: "target-1", + keyID: "key-1", + }, + res: res{}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore(t), + } + _, err := c.DeactivateTargetPublicKey(tt.args.ctx, tt.args.targetID, tt.args.keyID, tt.args.resourceOwner) + assert.ErrorIs(t, err, tt.res.err) + }) + } +} + +func TestCommands_RemoveTargetPublicKey(t *testing.T) { + type fields struct { + eventstore func(t *testing.T) *eventstore.Eventstore + } + type args struct { + ctx context.Context + targetID string + keyID string + resourceOwner string + } + type res struct { + err error + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + name: "missing target id", + fields: fields{ + eventstore: expectEventstore(), + }, + args: args{ + ctx: context.Background(), + }, + res: res{ + err: zerrors.ThrowInvalidArgument(nil, ErrTargetIDMissing, "Errors.IDMissing"), + }, + }, + { + name: "missing key id", + fields: fields{ + eventstore: expectEventstore(), + }, + args: args{ + ctx: context.Background(), + targetID: "target-1", + }, + res: res{ + err: zerrors.ThrowInvalidArgument(nil, ErrTargetIDMissing, "Errors.IDMissing"), + }, + }, + { + name: "target not found", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + ), + }, + args: args{ + ctx: context.Background(), + targetID: "target-1", + keyID: "key-1", + }, + res: res{ + err: nil, + }, + }, + { + name: "key active", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + targetAddEvent("target-1", "instance1"), + ), + eventFromEventPusher( + target.NewKeyAddedEvent(context.Background(), + target.NewAggregate("target-1", "instance1"), + "key-1", + rsaPublicKeyBytes, + rsaFingerprint, + time.Time{}, + ), + ), + eventFromEventPusher( + target.NewKeyActivatedEvent(context.Background(), + target.NewAggregate("target-1", "instance1"), + "key-1", + ), + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + targetID: "target-1", + keyID: "key-1", + }, + res: res{ + err: zerrors.ThrowPreconditionFailed(nil, ErrPublicKeyDeleteActiveKey, "Errors.Target.PublicKeyActive"), + }, + }, + { + name: "success", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + targetAddEvent("target-1", "instance1"), + ), + eventFromEventPusher( + target.NewKeyAddedEvent(context.Background(), + target.NewAggregate("target-1", "instance1"), + "key-1", + rsaPublicKeyBytes, + rsaFingerprint, + time.Time{}, + ), + ), + ), + expectPush( + eventFromEventPusher( + target.NewKeyRemovedEvent(context.Background(), + target.NewAggregate("target-1", "instance1"), + "key-1", + ), + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + targetID: "target-1", + keyID: "key-1", + }, + res: res{}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore(t), + } + _, err := c.RemoveTargetPublicKey(tt.args.ctx, tt.args.targetID, tt.args.keyID, tt.args.resourceOwner) + assert.ErrorIs(t, err, tt.res.err) + }) + } +} diff --git a/internal/command/action_v2_target_model.go b/internal/command/action_v2_target_model.go index 75532aa06..d0ff87d8e 100644 --- a/internal/command/action_v2_target_model.go +++ b/internal/command/action_v2_target_model.go @@ -21,6 +21,7 @@ type TargetWriteModel struct { Timeout time.Duration InterruptOnError bool SigningKey *crypto.CryptoValue + PayloadType target_domain.PayloadType State domain.TargetState } @@ -45,6 +46,7 @@ func (wm *TargetWriteModel) Reduce() error { wm.Timeout = e.Timeout wm.State = domain.TargetActive wm.SigningKey = e.SigningKey + wm.PayloadType = e.PayloadType case *target.ChangedEvent: if e.Name != nil { wm.Name = *e.Name @@ -64,6 +66,9 @@ func (wm *TargetWriteModel) Reduce() error { if e.SigningKey != nil { wm.SigningKey = e.SigningKey } + if e.PayloadType != target_domain.PayloadTypeUnspecified { + wm.PayloadType = e.PayloadType + } case *target.RemovedEvent: wm.State = domain.TargetRemoved } @@ -92,6 +97,7 @@ func (wm *TargetWriteModel) NewChangedEvent( timeout *time.Duration, interruptOnError *bool, signingKey *crypto.CryptoValue, + payloadType target_domain.PayloadType, ) *target.ChangedEvent { changes := make([]target.Changes, 0) if name != nil && wm.Name != *name { @@ -113,6 +119,9 @@ func (wm *TargetWriteModel) NewChangedEvent( if signingKey != nil { changes = append(changes, target.ChangeSigningKey(signingKey)) } + if payloadType != target_domain.PayloadTypeUnspecified && wm.PayloadType != payloadType { + changes = append(changes, target.ChangePayloadType(payloadType)) + } if len(changes) == 0 { return nil } diff --git a/internal/command/action_v2_target_model_test.go b/internal/command/action_v2_target_model_test.go index 547577f94..fd5d9297b 100644 --- a/internal/command/action_v2_target_model_test.go +++ b/internal/command/action_v2_target_model_test.go @@ -27,6 +27,7 @@ func targetAddEvent(aggID, resourceOwner string) *target.AddedEvent { KeyID: "id", Crypted: []byte("12345678"), }, + target_domain.PayloadTypeJSON, ) } diff --git a/internal/command/action_v2_target_test.go b/internal/command/action_v2_target_test.go index 2f2f3650c..57bea2085 100644 --- a/internal/command/action_v2_target_test.go +++ b/internal/command/action_v2_target_test.go @@ -140,6 +140,7 @@ func TestCommands_AddTarget(t *testing.T) { KeyID: "id", Crypted: []byte("12345678"), }, + target_domain.PayloadTypeJSON, ), ), ), @@ -150,10 +151,11 @@ func TestCommands_AddTarget(t *testing.T) { args{ ctx: context.Background(), add: &AddTarget{ - Name: "name", - Endpoint: "https://example.com", - Timeout: time.Second, - TargetType: target_domain.TargetTypeWebhook, + Name: "name", + Endpoint: "https://example.com", + Timeout: time.Second, + TargetType: target_domain.TargetTypeWebhook, + PayloadType: target_domain.PayloadTypeJSON, }, resourceOwner: "instance", }, @@ -203,10 +205,11 @@ func TestCommands_AddTarget(t *testing.T) { args{ ctx: context.Background(), add: &AddTarget{ - Name: "name", - TargetType: target_domain.TargetTypeWebhook, - Timeout: time.Second, - Endpoint: "https://example.com", + Name: "name", + TargetType: target_domain.TargetTypeWebhook, + Timeout: time.Second, + Endpoint: "https://example.com", + PayloadType: target_domain.PayloadTypeJSON, }, resourceOwner: "instance", }, @@ -239,6 +242,7 @@ func TestCommands_AddTarget(t *testing.T) { Endpoint: "https://example.com", Timeout: time.Second, InterruptOnError: true, + PayloadType: target_domain.PayloadTypeJSON, }, resourceOwner: "instance", }, @@ -514,6 +518,7 @@ func TestCommands_ChangeTarget(t *testing.T) { KeyID: "id", Crypted: []byte("12345678"), }), + target.ChangePayloadType(target_domain.PayloadTypeJWE), }, ), ), @@ -533,6 +538,7 @@ func TestCommands_ChangeTarget(t *testing.T) { Timeout: gu.Ptr(10 * time.Second), InterruptOnError: gu.Ptr(true), ExpirationSigningKey: true, + PayloadType: target_domain.PayloadTypeJWE, }, resourceOwner: "instance", }, diff --git a/internal/execution/execution.go b/internal/execution/execution.go index df23465fb..ffe39fa37 100644 --- a/internal/execution/execution.go +++ b/internal/execution/execution.go @@ -3,15 +3,22 @@ package execution import ( "bytes" "context" + "crypto/ecdsa" + "crypto/rsa" + "crypto/x509" "encoding/json" + "encoding/pem" "io" "net/http" + "sync" "time" + "github.com/go-jose/go-jose/v4" "github.com/zitadel/logging" "github.com/zitadel/zitadel/internal/api/authz" zhttp "github.com/zitadel/zitadel/internal/api/http" + "github.com/zitadel/zitadel/internal/api/oidc/sign" "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" target_domain "github.com/zitadel/zitadel/internal/execution/target" @@ -27,20 +34,29 @@ type ContextInfo interface { SetHTTPResponseBody([]byte) error } +type GetActiveSigningWebKey func(ctx context.Context) (*jose.JSONWebKey, error) + // CallTargets call a list of targets in order with handling of error and responses func CallTargets( ctx context.Context, targets []target_domain.Target, info ContextInfo, alg crypto.EncryptionAlgorithm, + activeSigningKey GetActiveSigningWebKey, ) (_ interface{}, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() + // We make sure the signer and its key are only fetched once per CallTargets call. + signerOnce := sign.GetSignerOnce(activeSigningKey) + // Create a map to cache encrypters by key ID to avoid recreating them for each target. + encrypters := &sync.Map{} + for _, target := range targets { // call the type of target - resp, err := CallTarget(ctx, target, info, alg) + resp, err := CallTarget(ctx, target, info, alg, signerOnce, encrypters) // handle error if interrupt is set + logging.WithFields("instanceID", authz.GetInstance(ctx).InstanceID(), "target", target.GetTargetID()).OnError(err).Error("error calling target") if err != nil && target.IsInterruptOnError() { return nil, err } @@ -64,6 +80,8 @@ func CallTarget( target target_domain.Target, info ContextInfoRequest, alg crypto.EncryptionAlgorithm, + signerOnce sign.SignerFunc, + encrypters *sync.Map, ) (res []byte, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() @@ -72,26 +90,137 @@ func CallTarget( if err != nil { return nil, zerrors.ThrowInternal(err, "EXEC-thiiCh5b", "Errors.Internal") } + body, err := payload(ctx, info.GetHTTPRequestBody(), target, signerOnce, encrypters) + if err != nil { + return nil, err + } switch target.GetTargetType() { // get request, ignore response and return request and error for handling in list of targets case target_domain.TargetTypeWebhook: - return nil, webhook(ctx, target.GetEndpoint(), target.GetTimeout(), info.GetHTTPRequestBody(), signingKey) + return nil, webhook(ctx, target.GetEndpoint(), target.GetTimeout(), body, signingKey) // get request, return response and error case target_domain.TargetTypeCall: - return Call(ctx, target.GetEndpoint(), target.GetTimeout(), info.GetHTTPRequestBody(), signingKey) + return Call(ctx, target.GetEndpoint(), target.GetTimeout(), body, signingKey) case target_domain.TargetTypeAsync: go func(ctx context.Context, target target_domain.Target, info []byte) { if _, err := Call(ctx, target.GetEndpoint(), target.GetTimeout(), info, signingKey); err != nil { logging.WithFields("target", target.GetTargetID()).OnError(err).Info(err) } - }(context.WithoutCancel(ctx), target, info.GetHTTPRequestBody()) + }(context.WithoutCancel(ctx), target, body) return nil, nil default: return nil, zerrors.ThrowInternal(nil, "EXEC-auqnansr2m", "Errors.Execution.Unknown") } } +func payload(ctx context.Context, payload []byte, target target_domain.Target, signerOnce sign.SignerFunc, encrypters *sync.Map) ([]byte, error) { + switch target.GetPayloadType() { + case target_domain.PayloadTypeUnspecified, + target_domain.PayloadTypeJSON: + return payload, nil + case target_domain.PayloadTypeJWT: + return payloadJWT(ctx, payload, signerOnce) + case target_domain.PayloadTypeJWE: + return payloadJWE(ctx, payload, target, signerOnce, encrypters) + default: + return payload, nil + } +} + +func payloadJWT(ctx context.Context, payload []byte, signerOnce sign.SignerFunc) ([]byte, error) { + signer, _, err := signerOnce(ctx) + if err != nil { + return nil, err + } + sig, err := signer.Sign(payload) + if err != nil { + return nil, err + } + data, err := sig.CompactSerialize() + if err != nil { + return nil, err + } + return []byte(data), nil +} + +func loadEncrypter(target target_domain.Target, encrypters *sync.Map) (jose.Encrypter, error) { + if encrypter, ok := encrypters.Load(target.GetEncryptionKeyID()); ok { + return encrypter.(jose.Encrypter), nil + } + encryptionKey := target.GetEncryptionKey() + if len(encryptionKey) == 0 { + return nil, zerrors.ThrowInternal(nil, "EXEC-2n8fhs7g", "Errors.Execution.MissingEncryptionKey") + } + publicKey, algorithm, err := publicKeyFromBytes(encryptionKey) + if err != nil { + return nil, err + } + + encrypter, err := jose.NewEncrypter( + jose.A256GCM, + jose.Recipient{ + Algorithm: algorithm, + Key: publicKey, + KeyID: target.GetEncryptionKeyID(), + }, + (&jose.EncrypterOptions{}). + WithType("JWT"). + WithContentType("JWT"), + ) + if err != nil { + return nil, err + } + encrypters.Store(target.GetEncryptionKeyID(), encrypter) + return encrypter, nil +} + +func payloadJWE( + ctx context.Context, + payload []byte, + target target_domain.Target, + signerOnce sign.SignerFunc, + encrypters *sync.Map, +) ([]byte, error) { + payload, err := payloadJWT(ctx, payload, signerOnce) + if err != nil { + return nil, err + } + encrypter, err := loadEncrypter(target, encrypters) + if err != nil { + return nil, err + } + + enc, err := encrypter.Encrypt(payload) + if err != nil { + return nil, err + } + crypted, err := enc.CompactSerialize() + if err != nil { + return nil, err + } + return []byte(crypted), nil +} + +func publicKeyFromBytes(data []byte) (any, jose.KeyAlgorithm, error) { + block, _ := pem.Decode(data) + if block == nil { + return nil, "", zerrors.ThrowInternal(nil, "EXEC-3n8fhs7g", "Errors.Execution.InvalidPublicKey") + } + publicKey, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + return nil, "", err + } + switch pk := publicKey.(type) { + case *rsa.PublicKey: + return pk, jose.RSA_OAEP_256, nil + case *ecdsa.PublicKey: + return pk, jose.ECDH_ES_A256KW, nil + default: + return nil, "", zerrors.ThrowInternal(nil, "EXEC-NKJe2", "Errors.Execution.InvalidPublicKey") + } +} + // webhook call a webhook, ignore the response but return the errror func webhook(ctx context.Context, url string, timeout time.Duration, body []byte, signingKey string) error { _, err := Call(ctx, url, timeout, body, signingKey) diff --git a/internal/execution/execution_test.go b/internal/execution/execution_test.go index df3c551a4..7174e2b2c 100644 --- a/internal/execution/execution_test.go +++ b/internal/execution/execution_test.go @@ -3,20 +3,26 @@ package execution_test import ( "bytes" "context" + "crypto/rand" + "crypto/rsa" "encoding/json" "errors" "io" "net/http" "net/http/httptest" + "sync" + "sync/atomic" "testing" "time" + "github.com/go-jose/go-jose/v4" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" "google.golang.org/protobuf/types/known/structpb" "github.com/zitadel/zitadel/internal/api/grpc/server/middleware" + "github.com/zitadel/zitadel/internal/api/oidc/sign" "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/execution" target_domain "github.com/zitadel/zitadel/internal/execution/target" @@ -111,7 +117,7 @@ func Test_Call(t *testing.T) { respBody, err := testServer(t, &callTestServer{ method: tt.args.method, - expectBody: tt.args.body, + expectBody: validateJSONPayload(tt.args.body), timeout: tt.args.sleep, statusCode: tt.args.statusCode, respondBody: tt.args.respBody, @@ -135,6 +141,7 @@ func Test_CallTarget(t *testing.T) { info *middleware.ContextInfoRequest server *callTestServer target target_domain.Target + signer func(ctx context.Context) (jose.Signer, jose.SignatureAlgorithm, error) } type res struct { body []byte @@ -152,7 +159,7 @@ func Test_CallTarget(t *testing.T) { info: requestContextInfo1, server: &callTestServer{ method: http.MethodPost, - expectBody: []byte("{\"request\":{\"content\":\"request1\"}}"), + expectBody: validateJSONPayload([]byte("{\"request\":{\"content\":\"request1\"}}")), respondBody: []byte("{\"content\":\"request2\"}"), timeout: time.Second, statusCode: http.StatusInternalServerError, @@ -173,7 +180,7 @@ func Test_CallTarget(t *testing.T) { server: &callTestServer{ timeout: time.Second, method: http.MethodPost, - expectBody: []byte("{\"request\":{\"content\":\"request1\"}}"), + expectBody: validateJSONPayload([]byte("{\"request\":{\"content\":\"request1\"}}")), respondBody: []byte("{\"content\":\"request2\"}"), statusCode: http.StatusInternalServerError, }, @@ -194,7 +201,7 @@ func Test_CallTarget(t *testing.T) { server: &callTestServer{ timeout: time.Second, method: http.MethodPost, - expectBody: []byte("{\"request\":{\"content\":\"request1\"}}"), + expectBody: validateJSONPayload([]byte("{\"request\":{\"content\":\"request1\"}}")), respondBody: []byte("{\"content\":\"request2\"}"), statusCode: http.StatusOK, }, @@ -215,7 +222,7 @@ func Test_CallTarget(t *testing.T) { server: &callTestServer{ timeout: time.Second, method: http.MethodPost, - expectBody: []byte("{\"request\":{\"content\":\"request1\"}}"), + expectBody: validateJSONPayload([]byte("{\"request\":{\"content\":\"request1\"}}")), respondBody: []byte("{\"content\":\"request2\"}"), statusCode: http.StatusOK, signingKey: "signingkey", @@ -242,7 +249,7 @@ func Test_CallTarget(t *testing.T) { server: &callTestServer{ timeout: time.Second, method: http.MethodPost, - expectBody: []byte("{\"request\":{\"content\":\"request1\"}}"), + expectBody: validateJSONPayload([]byte("{\"request\":{\"content\":\"request1\"}}")), respondBody: []byte("{\"content\":\"request2\"}"), statusCode: http.StatusInternalServerError, }, @@ -263,7 +270,7 @@ func Test_CallTarget(t *testing.T) { server: &callTestServer{ timeout: time.Second, method: http.MethodPost, - expectBody: []byte("{\"request\":{\"content\":\"request1\"}}"), + expectBody: validateJSONPayload([]byte("{\"request\":{\"content\":\"request1\"}}")), respondBody: []byte("{\"content\":\"request2\"}"), statusCode: http.StatusOK, }, @@ -284,7 +291,7 @@ func Test_CallTarget(t *testing.T) { server: &callTestServer{ timeout: time.Second, method: http.MethodPost, - expectBody: []byte("{\"request\":{\"content\":\"request1\"}}"), + expectBody: validateJSONPayload([]byte("{\"request\":{\"content\":\"request1\"}}")), respondBody: []byte("{\"content\":\"request2\"}"), statusCode: http.StatusOK, signingKey: "signingkey", @@ -303,10 +310,103 @@ func Test_CallTarget(t *testing.T) { body: []byte("{\"content\":\"request2\"}"), }, }, + { + "webhook, JWT, ok", + args{ + ctx: context.Background(), + info: requestContextInfo1, + server: &callTestServer{ + timeout: time.Second, + method: http.MethodPost, + expectBody: validateJWTPayload([]byte("{\"request\":{\"content\":\"request1\"}}")), + respondBody: []byte("{\"content\":\"request2\"}"), + statusCode: http.StatusOK, + signingKey: "signingkey", + }, + target: target_domain.Target{ + TargetType: target_domain.TargetTypeWebhook, + Timeout: time.Minute, + SigningKey: &crypto.CryptoValue{ + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("signingkey"), + }, + PayloadType: target_domain.PayloadTypeJWT, + }, + signer: mockSigner, + }, + res{ + body: nil, + }, + }, + { + "webhook, JWE, ok", + args{ + ctx: context.Background(), + info: requestContextInfo1, + server: &callTestServer{ + timeout: time.Second, + method: http.MethodPost, + expectBody: validateJWEPayload([]byte("{\"request\":{\"content\":\"request1\"}}")), + respondBody: []byte("{\"content\":\"request2\"}"), + statusCode: http.StatusOK, + signingKey: "signingkey", + }, + target: target_domain.Target{ + TargetType: target_domain.TargetTypeWebhook, + Timeout: time.Minute, + SigningKey: &crypto.CryptoValue{ + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("signingkey"), + }, + PayloadType: target_domain.PayloadTypeJWE, + EncryptionKey: encryptionKey, + EncryptionKeyID: encryptionKeyID, + }, + signer: mockSigner, + }, + res{ + body: nil, + }, + }, + { + "webhook, JWE no encryption key, error", + args{ + ctx: context.Background(), + info: requestContextInfo1, + server: &callTestServer{ + timeout: time.Second, + method: http.MethodPost, + expectBody: validateJWEPayload([]byte("{\"request\":{\"content\":\"request1\"}}")), + respondBody: []byte("{\"content\":\"request2\"}"), + statusCode: http.StatusOK, + signingKey: "signingkey", + }, + target: target_domain.Target{ + TargetType: target_domain.TargetTypeWebhook, + Timeout: time.Minute, + SigningKey: &crypto.CryptoValue{ + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("signingkey"), + }, + PayloadType: target_domain.PayloadTypeJWE, + }, + signer: mockSigner, + }, + res{ + wantErr: true, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - respBody, err := testServer(t, tt.args.server, testCallTarget(tt.args.ctx, tt.args.info, tt.args.target, crypto.CreateMockEncryptionAlg(gomock.NewController(t)))) + respBody, err := testServer( + t, + tt.args.server, + testCallTarget(tt.args.ctx, tt.args.info, tt.args.target, crypto.CreateMockEncryptionAlg(gomock.NewController(t)), tt.args.signer, &sync.Map{}), + ) if tt.res.wantErr { assert.Error(t, err) } else { @@ -319,10 +419,11 @@ func Test_CallTarget(t *testing.T) { func Test_CallTargets(t *testing.T) { type args struct { - ctx context.Context - info *middleware.ContextInfoRequest - servers []*callTestServer - targets []target_domain.Target + ctx context.Context + info *middleware.ContextInfoRequest + servers []*callTestServer + targets []target_domain.Target + getActiveSigningWebKey func(*int32) execution.GetActiveSigningWebKey } type res struct { ret interface{} @@ -341,13 +442,13 @@ func Test_CallTargets(t *testing.T) { servers: []*callTestServer{{ timeout: time.Second, method: http.MethodPost, - expectBody: requestContextInfoBody1, + expectBody: validateJSONPayload(requestContextInfoBody1), respondBody: requestContextInfoBody2, statusCode: http.StatusInternalServerError, }, { timeout: time.Second, method: http.MethodPost, - expectBody: requestContextInfoBody1, + expectBody: validateJSONPayload(requestContextInfoBody1), respondBody: requestContextInfoBody2, statusCode: http.StatusInternalServerError, }}, @@ -368,13 +469,13 @@ func Test_CallTargets(t *testing.T) { servers: []*callTestServer{{ timeout: time.Second, method: http.MethodPost, - expectBody: requestContextInfoBody1, + expectBody: validateJSONPayload(requestContextInfoBody1), respondBody: requestContextInfoBody2, statusCode: http.StatusInternalServerError, }, { timeout: time.Second, method: http.MethodPost, - expectBody: requestContextInfoBody1, + expectBody: validateJSONPayload(requestContextInfoBody1), respondBody: requestContextInfoBody2, statusCode: http.StatusInternalServerError, }}, @@ -395,13 +496,13 @@ func Test_CallTargets(t *testing.T) { servers: []*callTestServer{{ timeout: time.Second, method: http.MethodPost, - expectBody: requestContextInfoBody1, + expectBody: validateJSONPayload(requestContextInfoBody1), respondBody: requestContextInfoBody2, statusCode: http.StatusOK, }, { timeout: time.Second, method: http.MethodPost, - expectBody: requestContextInfoBody1, + expectBody: validateJSONPayload(requestContextInfoBody1), respondBody: []byte("just a string, not json"), statusCode: http.StatusOK, }}, @@ -422,13 +523,13 @@ func Test_CallTargets(t *testing.T) { servers: []*callTestServer{{ timeout: time.Second, method: http.MethodPost, - expectBody: requestContextInfoBody1, + expectBody: validateJSONPayload(requestContextInfoBody1), respondBody: requestContextInfoBody2, statusCode: http.StatusOK, }, { timeout: time.Second, method: http.MethodPost, - expectBody: requestContextInfoBody1, + expectBody: validateJSONPayload(requestContextInfoBody1), respondBody: []byte("just a string, not json"), statusCode: http.StatusOK, }}, @@ -440,12 +541,53 @@ func Test_CallTargets(t *testing.T) { ret: requestContextInfo1.GetContent(), }, }, + { + "multiple JWT/JWE targets, ok", + args{ + ctx: context.Background(), + info: requestContextInfo1, + servers: []*callTestServer{{ + timeout: time.Second, + method: http.MethodPost, + expectBody: validateJWTPayload(requestContextInfoBody1), + respondBody: requestContextInfoBody2, + statusCode: http.StatusOK, + }, { + timeout: time.Second, + method: http.MethodPost, + expectBody: validateJSONPayload(requestContextInfoBody1), + respondBody: requestContextInfoBody2, + statusCode: http.StatusOK, + }}, + targets: []target_domain.Target{ + { + TargetType: target_domain.TargetTypeWebhook, + PayloadType: target_domain.PayloadTypeJWT, + Timeout: time.Minute, + }, + { + TargetType: target_domain.TargetTypeWebhook, + PayloadType: target_domain.PayloadTypeJWE, + Timeout: time.Minute, + }, + }, + getActiveSigningWebKey: testActiveSingingWebKey, + }, + res{ + ret: requestContextInfo1.GetContent(), + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + var getWebKeyCalls int32 + var getActiveSigningWebKey execution.GetActiveSigningWebKey + if tt.args.getActiveSigningWebKey != nil { + getActiveSigningWebKey = tt.args.getActiveSigningWebKey(&getWebKeyCalls) + } respBody, err := testServers(t, tt.args.servers, - testCallTargets(tt.args.ctx, tt.args.info, tt.args.targets, crypto.CreateMockEncryptionAlg(gomock.NewController(t))), + testCallTargets(tt.args.ctx, tt.args.info, tt.args.targets, crypto.CreateMockEncryptionAlg(gomock.NewController(t)), getActiveSigningWebKey), ) if tt.res.wantErr { assert.Error(t, err) @@ -453,17 +595,27 @@ func Test_CallTargets(t *testing.T) { assert.NoError(t, err) } assert.Equal(t, tt.res.ret, respBody) + var expectedCalls int32 + if tt.args.getActiveSigningWebKey != nil { + expectedCalls = 1 + } + assert.Equal(t, expectedCalls, atomic.LoadInt32(&getWebKeyCalls)) }) } } type callTestServer struct { method string - expectBody []byte + expectBody func(*testing.T, []byte) bool timeout time.Duration statusCode int respondBody []byte signingKey string + called bool +} + +func (s *callTestServer) Called() bool { + return s.called } func testServers( @@ -473,8 +625,8 @@ func testServers( ) (interface{}, error) { urls := make([]string, len(c)) for i := range c { - url, close := listen(t, c[i]) - defer close() + url, closeF, _ := listen(t, c[i]) + defer closeF() urls[i] = url } return call(urls) @@ -485,16 +637,17 @@ func testServer( c *callTestServer, call func(string) ([]byte, error), ) ([]byte, error) { - url, close := listen(t, c) - defer close() + url, closeF, _ := listen(t, c) + defer closeF() return call(url) } func listen( t *testing.T, c *callTestServer, -) (url string, close func()) { +) (url string, close func(), called func() bool) { handler := func(w http.ResponseWriter, r *http.Request) { + c.called = true checkRequest(t, r, c.method, c.expectBody, c.signingKey) if c.statusCode != http.StatusOK { @@ -511,13 +664,15 @@ func listen( } } server := httptest.NewServer(http.HandlerFunc(handler)) - return server.URL, server.Close + return server.URL, server.Close, c.Called } -func checkRequest(t *testing.T, sent *http.Request, method string, expectedBody []byte, signingKey string) { +func checkRequest(t *testing.T, sent *http.Request, method string, checkExpectedBody func(*testing.T, []byte) bool, signingKey string) { sentBody, err := io.ReadAll(sent.Body) require.NoError(t, err) - require.Equal(t, expectedBody, sentBody) + if !checkExpectedBody(t, sentBody) { + return + } require.Equal(t, method, sent.Method) if signingKey != "" { require.NoError(t, actions.ValidatePayload(sentBody, sent.Header.Get(actions.SigningHeader), signingKey)) @@ -534,17 +689,20 @@ func testCallTarget(ctx context.Context, info *middleware.ContextInfoRequest, target target_domain.Target, alg crypto.EncryptionAlgorithm, + signerOnce sign.SignerFunc, + encrypters *sync.Map, ) func(string) ([]byte, error) { return func(url string) (r []byte, err error) { target.Endpoint = url - return execution.CallTarget(ctx, target, info, alg) + return execution.CallTarget(ctx, target, info, alg, signerOnce, encrypters) } } func testCallTargets(ctx context.Context, - info *middleware.ContextInfoRequest, + info execution.ContextInfo, target []target_domain.Target, alg crypto.EncryptionAlgorithm, + activeSigningKey execution.GetActiveSigningWebKey, ) func([]string) (interface{}, error) { return func(urls []string) (interface{}, error) { targets := make([]target_domain.Target, len(target)) @@ -552,7 +710,7 @@ func testCallTargets(ctx context.Context, t.Endpoint = urls[i] targets[i] = t } - return execution.CallTargets(ctx, targets, info, alg) + return execution.CallTargets(ctx, targets, info, alg, activeSigningKey) } } @@ -717,3 +875,67 @@ func Test_handleResponse(t *testing.T) { } } + +var ( + privateKey = func() *rsa.PrivateKey { + privateKey, _ := rsa.GenerateKey(rand.Reader, 2048) + return privateKey + }() + encryptionKey = func() []byte { + data, _ := crypto.PublicKeyToBytes(&privateKey.PublicKey) + return data + }() + encryptionKeyID = "encryption-key-id" + signingAlgorithm = jose.RS256 +) + +func mockSigner(ctx context.Context) (jose.Signer, jose.SignatureAlgorithm, error) { + return sign.GetSignerOnce(mockGetActiveSigningWebKey)(ctx) +} + +func mockGetActiveSigningWebKey(ctx context.Context) (*jose.JSONWebKey, error) { + return &jose.JSONWebKey{ + Key: privateKey, + Algorithm: string(signingAlgorithm), + Use: "sig", + }, nil +} + +func testActiveSingingWebKey(getWebKeyCalls *int32) execution.GetActiveSigningWebKey { + return func(ctx context.Context) (*jose.JSONWebKey, error) { + atomic.AddInt32(getWebKeyCalls, 1) + return mockGetActiveSigningWebKey(ctx) + } +} + +func validateJSONPayload(expected []byte) func(*testing.T, []byte) bool { + return func(t *testing.T, actual []byte) bool { + require.Equal(t, expected, actual) + return false + } +} + +func validateJWTPayload(expected []byte) func(*testing.T, []byte) bool { + return func(t *testing.T, actual []byte) bool { + jws, err := jose.ParseSigned(string(actual), []jose.SignatureAlgorithm{jose.RS256}) + require.NoError(t, err) + payload, err := jws.Verify(privateKey.Public()) + require.NoError(t, err) + return bytes.Equal(expected, payload) + } +} + +func validateJWEPayload(expected []byte) func(*testing.T, []byte) bool { + return func(t *testing.T, actual []byte) bool { + parsedJWE, err := jose.ParseEncrypted(string(actual), []jose.KeyAlgorithm{jose.RSA_OAEP_256, jose.ECDH_ES_A256KW}, []jose.ContentEncryption{jose.A256GCM}) + if err != nil { + return false + } + require.Equal(t, encryptionKeyID, parsedJWE.Header.KeyID) + require.Equal(t, "JWT", parsedJWE.Header.ExtraHeaders[jose.HeaderContentType].(string)) + + decryptedJWS, err := parsedJWE.Decrypt(privateKey) + require.NoError(t, err) + return validateJWTPayload(expected)(t, decryptedJWS) + } +} diff --git a/internal/execution/projections.go b/internal/execution/projections.go index 88fbf7c7b..0b336d4b2 100644 --- a/internal/execution/projections.go +++ b/internal/execution/projections.go @@ -2,6 +2,7 @@ package execution import ( "context" + "time" "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/eventstore/handler/v2" @@ -16,9 +17,10 @@ func Register( workerConfig WorkerConfig, queue *queue.Queue, targetEncAlg crypto.EncryptionAlgorithm, + activeSigningKey GetActiveSigningWebKey, ) { queue.ShouldStart() - queue.AddWorkers(NewWorker(workerConfig, targetEncAlg)) + queue.AddWorkers(NewWorker(workerConfig, targetEncAlg, activeSigningKey, time.Now)) } func Start(ctx context.Context) { diff --git a/internal/execution/target/target.go b/internal/execution/target/target.go index 108d4ecd7..2d62cbb19 100644 --- a/internal/execution/target/target.go +++ b/internal/execution/target/target.go @@ -14,6 +14,15 @@ const ( TargetTypeAsync ) +type PayloadType uint + +const ( + PayloadTypeUnspecified PayloadType = iota + PayloadTypeJSON + PayloadTypeJWT + PayloadTypeJWE +) + type Target struct { ExecutionID string `json:"execution_id,omitempty"` TargetID string `json:"target_id,omitempty"` @@ -22,6 +31,9 @@ type Target struct { Timeout time.Duration `json:"timeout,omitempty"` InterruptOnError bool `json:"interrupt_on_error,omitempty"` SigningKey *crypto.CryptoValue `json:"signing_key,omitempty"` + PayloadType PayloadType `json:"payload_type,omitempty"` + EncryptionKey []byte `json:"encryption_key,omitempty"` + EncryptionKeyID string `json:"encryption_key_id,omitempty"` } func (e *Target) GetExecutionID() string { @@ -48,3 +60,13 @@ func (e *Target) GetSigningKey(alg crypto.EncryptionAlgorithm) (string, error) { } return crypto.DecryptString(e.SigningKey, alg) } +func (e *Target) GetPayloadType() PayloadType { + return e.PayloadType +} +func (e *Target) GetEncryptionKey() []byte { + return e.EncryptionKey +} + +func (e *Target) GetEncryptionKeyID() string { + return e.EncryptionKeyID +} diff --git a/internal/execution/target_test.go b/internal/execution/target_test.go deleted file mode 100644 index 8df480219..000000000 --- a/internal/execution/target_test.go +++ /dev/null @@ -1,85 +0,0 @@ -package execution - -import ( - "encoding/json" - "io" - "net/http" - "net/http/httptest" - "reflect" - "time" -) - -type testServer struct { - server *httptest.Server - called bool -} - -func (s *testServer) URL() string { - return s.server.URL -} - -func (s *testServer) Close() { - s.server.Close() -} - -func (s *testServer) Called() bool { - return s.called -} - -func testServerCall( - reqBody interface{}, - sleep time.Duration, - statusCode int, - respBody interface{}, -) (string, func(), func() bool) { - server := &testServer{ - called: false, - } - - handler := func(w http.ResponseWriter, r *http.Request) { - server.called = true - if reqBody != nil { - data, err := json.Marshal(reqBody) - if err != nil { - http.Error(w, "error, marshall: "+err.Error(), http.StatusInternalServerError) - return - } - sentBody, err := io.ReadAll(r.Body) - if err != nil { - http.Error(w, "error, read body: "+err.Error(), http.StatusInternalServerError) - return - } - if !reflect.DeepEqual(data, sentBody) { - http.Error(w, "error, equal:\n"+string(data)+"\nsent:\n"+string(sentBody), http.StatusInternalServerError) - return - } - } - if statusCode != http.StatusOK { - http.Error(w, "error, statusCode", statusCode) - return - } - - time.Sleep(sleep) - - if respBody != nil { - w.Header().Set("Content-Type", "application/json") - resp, err := json.Marshal(respBody) - if err != nil { - http.Error(w, "error", http.StatusInternalServerError) - return - } - if _, err := w.Write(resp); err != nil { - http.Error(w, "error", http.StatusInternalServerError) - return - } - } else { - if _, err := io.WriteString(w, "finished successfully"); err != nil { - http.Error(w, "error", http.StatusInternalServerError) - return - } - } - } - - server.server = httptest.NewServer(http.HandlerFunc(handler)) - return server.URL(), server.Close, server.Called -} diff --git a/internal/execution/worker.go b/internal/execution/worker.go index 3da069d47..f5d05d56a 100644 --- a/internal/execution/worker.go +++ b/internal/execution/worker.go @@ -18,9 +18,10 @@ type Worker struct { river.WorkerDefaults[*exec_repo.Request] config WorkerConfig - now nowFunc + now NowFunc - targetEncAlg crypto.EncryptionAlgorithm + targetEncAlg crypto.EncryptionAlgorithm + activeSigningKey GetActiveSigningWebKey } // Timeout implements the Timeout-function of [river.Worker]. @@ -45,7 +46,7 @@ func (w *Worker) Work(ctx context.Context, job *river.Job[*exec_repo.Request]) e return river.JobCancel(fmt.Errorf("unable to unmarshal targets because %w", err)) } - _, err = CallTargets(ctx, targets, exec_repo.ContextInfoFromRequest(job.Args), w.targetEncAlg) + _, err = CallTargets(ctx, targets, exec_repo.ContextInfoFromRequest(job.Args), w.targetEncAlg, w.activeSigningKey) if err != nil { // If there is an error returned from the targets, it means that the execution was interrupted return river.JobCancel(fmt.Errorf("interruption during call of targets because %w", err)) @@ -53,8 +54,8 @@ func (w *Worker) Work(ctx context.Context, job *river.Job[*exec_repo.Request]) e return nil } -// nowFunc makes [time.Now] mockable -type nowFunc func() time.Time +// NowFunc makes [time.Now] mockable +type NowFunc func() time.Time type WorkerConfig struct { Workers uint8 @@ -65,11 +66,14 @@ type WorkerConfig struct { func NewWorker( config WorkerConfig, targetEncAlg crypto.EncryptionAlgorithm, + activeSigningKey GetActiveSigningWebKey, + now NowFunc, ) *Worker { return &Worker{ - config: config, - now: time.Now, - targetEncAlg: targetEncAlg, + config: config, + now: now, + targetEncAlg: targetEncAlg, + activeSigningKey: activeSigningKey, } } diff --git a/internal/execution/worker_test.go b/internal/execution/worker_test.go index 1f8aac90b..2883c1aea 100644 --- a/internal/execution/worker_test.go +++ b/internal/execution/worker_test.go @@ -1,4 +1,4 @@ -package execution +package execution_test import ( "context" @@ -15,6 +15,7 @@ import ( "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/execution" target_domain "github.com/zitadel/zitadel/internal/execution/target" "github.com/zitadel/zitadel/internal/repository/action" exec_repo "github.com/zitadel/zitadel/internal/repository/execution" @@ -23,26 +24,44 @@ import ( ) type fieldsWorker struct { - now nowFunc + now execution.NowFunc } type argsWorker struct { job *river.Job[*exec_repo.Request] } type wantWorker struct { - targets []target_domain.Target + targets []target sendStatusCode int err assert.ErrorAssertionFunc } -func newExecutionWorker(f fieldsWorker) *Worker { - return &Worker{ - config: WorkerConfig{ +type target target_domain.Target + +func (t *target) validate(expectedBody []byte) func(*testing.T, []byte) bool { + switch t.PayloadType { + case target_domain.PayloadTypeUnspecified, + target_domain.PayloadTypeJSON: + return validateJSONPayload(expectedBody) + case target_domain.PayloadTypeJWT: + return validateJWTPayload(expectedBody) + case target_domain.PayloadTypeJWE: + return validateJWEPayload(expectedBody) + default: + return validateJSONPayload(expectedBody) + } +} + +func newExecutionWorker(f fieldsWorker) *execution.Worker { + return execution.NewWorker( + execution.WorkerConfig{ Workers: 1, TransactionDuration: 5 * time.Second, MaxTtl: 5 * time.Minute, }, - now: f.now, - } + nil, + mockGetActiveSigningWebKey, + f.now, + ) } const ( @@ -85,7 +104,7 @@ func Test_handleEventExecution(t *testing.T) { }, }, wantWorker{ - targets: mockTargets(1), + targets: mockTargets(target_domain.PayloadTypeJSON), sendStatusCode: http.StatusOK, err: func(tt assert.TestingT, err error, i ...interface{}) bool { return errors.Is(err, new(river.JobCancelError)) @@ -153,7 +172,7 @@ func Test_handleEventExecution(t *testing.T) { }, }, wantWorker{ - targets: mockTargets(1), + targets: mockTargets(target_domain.PayloadTypeJSON), sendStatusCode: http.StatusOK, err: nil, } @@ -187,7 +206,7 @@ func Test_handleEventExecution(t *testing.T) { }, }, wantWorker{ - targets: mockTargets(1), + targets: mockTargets(target_domain.PayloadTypeJSON), sendStatusCode: http.StatusBadRequest, err: func(tt assert.TestingT, err error, i ...interface{}) bool { return errors.Is(err, zerrors.ThrowPreconditionFailed(nil, "EXEC-dra6yamk98", "Errors.Execution.Failed")) @@ -223,7 +242,7 @@ func Test_handleEventExecution(t *testing.T) { }, }, wantWorker{ - targets: mockTargets(3), + targets: mockTargets(target_domain.PayloadTypeJSON, target_domain.PayloadTypeJWT, target_domain.PayloadTypeJWE), sendStatusCode: http.StatusOK, err: nil, } @@ -234,15 +253,19 @@ func Test_handleEventExecution(t *testing.T) { t.Run(tt.name, func(t *testing.T) { f, a, w := tt.test() + body, err := json.Marshal(exec_repo.ContextInfoFromRequest(a.job.Args)) + require.NoError(t, err) + closeFuncs := make([]func(), len(w.targets)) calledFuncs := make([]func() bool, len(w.targets)) for i := range w.targets { - url, closeF, calledF := testServerCall( - exec_repo.ContextInfoFromRequest(a.job.Args), - time.Second, - w.sendStatusCode, - nil, - ) + url, closeF, calledF := listen(t, &callTestServer{ + method: http.MethodPost, + expectBody: w.targets[i].validate(body), + timeout: time.Second, + statusCode: w.sendStatusCode, + respondBody: nil, + }) w.targets[i].Endpoint = url closeFuncs[i] = closeF calledFuncs[i] = calledF @@ -273,24 +296,24 @@ func Test_handleEventExecution(t *testing.T) { } } -func mockTarget() target_domain.Target { - return target_domain.Target{ +func mockTarget(payloadType target_domain.PayloadType) target { + return target{ ExecutionID: "executionID", TargetID: "targetID", TargetType: target_domain.TargetTypeWebhook, Endpoint: "endpoint", Timeout: time.Minute, InterruptOnError: true, + PayloadType: payloadType, + EncryptionKey: encryptionKey, + EncryptionKeyID: encryptionKeyID, } } -func mockTargets(count int) []target_domain.Target { - var targets []target_domain.Target - if count > 0 { - targets = make([]target_domain.Target, count) - for i := range targets { - targets[i] = mockTarget() - } +func mockTargets(payloadTypes ...target_domain.PayloadType) []target { + targets := make([]target, len(payloadTypes)) + for i, payloadType := range payloadTypes { + targets[i] = mockTarget(payloadType) } return targets } diff --git a/internal/integration/client.go b/internal/integration/client.go index 9168366a4..0746eca96 100644 --- a/internal/integration/client.go +++ b/internal/integration/client.go @@ -1176,14 +1176,19 @@ func (i *Instance) DeleteProjectGrantMembership(t *testing.T, ctx context.Contex require.NoError(t, err) } -func (i *Instance) CreateTarget(ctx context.Context, t *testing.T, name, endpoint string, ty target_domain.TargetType, interrupt bool) *action.CreateTargetResponse { +func (i *Instance) CreateTargetWithoutPayloadType(ctx context.Context, t *testing.T, name, endpoint string, ty target_domain.TargetType, interrupt bool) *action.CreateTargetResponse { + return i.CreateTarget(ctx, t, name, endpoint, ty, interrupt, action.PayloadType_PAYLOAD_TYPE_UNSPECIFIED) +} + +func (i *Instance) CreateTarget(ctx context.Context, t *testing.T, name, endpoint string, ty target_domain.TargetType, interrupt bool, payloadType action.PayloadType) *action.CreateTargetResponse { if name == "" { name = TargetName() } req := &action.CreateTargetRequest{ - Name: name, - Endpoint: endpoint, - Timeout: durationpb.New(5 * time.Second), + Name: name, + Endpoint: endpoint, + Timeout: durationpb.New(5 * time.Second), + PayloadType: payloadType, } switch ty { case target_domain.TargetTypeWebhook: diff --git a/internal/notification/handlers/back_channel_logout.go b/internal/notification/handlers/back_channel_logout.go index 12c2c708e..6f6847939 100644 --- a/internal/notification/handlers/back_channel_logout.go +++ b/internal/notification/handlers/back_channel_logout.go @@ -12,7 +12,7 @@ import ( "github.com/zitadel/zitadel/internal/api/authz" http_utils "github.com/zitadel/zitadel/internal/api/http" - zoidc "github.com/zitadel/zitadel/internal/api/oidc" + "github.com/zitadel/zitadel/internal/api/oidc/sign" "github.com/zitadel/zitadel/internal/command" zcrypto "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/eventstore" @@ -147,7 +147,7 @@ func (u *backChannelLogoutNotifier) terminateSession(ctx context.Context, id str return err } - getSigner := zoidc.GetSignerOnce(u.queries.GetActiveSigningWebKey) + getSigner := sign.GetSignerOnce(u.queries.GetActiveSigningWebKey) var wg sync.WaitGroup wg.Add(len(sessions.sessions)) @@ -170,7 +170,7 @@ func (u *backChannelLogoutNotifier) terminateSession(ctx context.Context, id str return errors.Join(errs...) } -func (u *backChannelLogoutNotifier) sendLogoutToken(ctx context.Context, oidcSession *backChannelLogoutOIDCSessions, e eventstore.Event, getSigner zoidc.SignerFunc) error { +func (u *backChannelLogoutNotifier) sendLogoutToken(ctx context.Context, oidcSession *backChannelLogoutOIDCSessions, e eventstore.Event, getSigner sign.SignerFunc) error { token, err := u.logoutToken(ctx, oidcSession, getSigner) if err != nil { return err @@ -182,7 +182,7 @@ func (u *backChannelLogoutNotifier) sendLogoutToken(ctx context.Context, oidcSes return nil } -func (u *backChannelLogoutNotifier) logoutToken(ctx context.Context, oidcSession *backChannelLogoutOIDCSessions, getSigner zoidc.SignerFunc) (string, error) { +func (u *backChannelLogoutNotifier) logoutToken(ctx context.Context, oidcSession *backChannelLogoutOIDCSessions, getSigner sign.SignerFunc) (string, error) { jwtID, err := u.idGenerator.Next() if err != nil { return "", err diff --git a/internal/query/authn_key.go b/internal/query/authn_key.go index a66212b32..43e9b8e19 100644 --- a/internal/query/authn_key.go +++ b/internal/query/authn_key.go @@ -84,6 +84,10 @@ var ( name: projection.AuthNKeyEnabledCol, table: authNKeyTable, } + AuthNKeyColumnFingerprint = Column{ + name: projection.AuthNKeyFingerprintCol, + table: authNKeyTable, + } ) type AuthNKeys struct { @@ -100,8 +104,11 @@ type AuthNKey struct { Sequence uint64 ApplicationID string - Expiration time.Time - Type domain.AuthNKeyType + Expiration time.Time + Type domain.AuthNKeyType + Enabled bool + PublicKey []byte + Fingerprint string } type AuthNKeysData struct { @@ -141,6 +148,7 @@ const ( JoinFilterUnspecified JoinFilter = iota JoinFilterApp JoinFilterUserMachine + JoinFilterTarget ) // SearchAuthNKeys returns machine or app keys, depending on the join filter. @@ -173,11 +181,17 @@ func (q *Queries) searchAuthNKeys(ctx context.Context, queries *AuthNKeySearchQu case JoinFilterUserMachine: query = query.Join(join(MachineUserIDCol, AuthNKeyColumnIdentifier)) query = userPermissionCheckV2WithCustomColumns(ctx, query, permissionCheckV2, queries.Queries, AuthNKeyColumnResourceOwner, AuthNKeyColumnIdentifier) + case JoinFilterTarget: + query = query.Join(join(TargetColumnID, AuthNKeyColumnObjectID)) } eq := sq.Eq{ - AuthNKeyColumnEnabled.identifier(): true, AuthNKeyColumnInstanceID.identifier(): authz.GetInstance(ctx).InstanceID(), } + // Until now all disabled keys were just filtered out, but target keys can be explicitly disabled and + // the state needs to be returned rather than filtering them out. + if joinFilter != JoinFilterTarget { + eq[AuthNKeyColumnEnabled.identifier()] = true + } stmt, args, err := query.Where(eq).ToSql() if err != nil { @@ -283,6 +297,14 @@ func NewAuthNKeyIDQuery(id string) (SearchQuery, error) { return NewTextQuery(AuthNKeyColumnID, id, TextEquals) } +func NewAuthNKeyInIDsSearchQuery(ids []string) (SearchQuery, error) { + return NewInTextQuery(AuthNKeyColumnID, ids) +} + +func NewAuthNKeyEnabledSearchQuery(enabled bool) (SearchQuery, error) { + return NewBoolQuery(AuthNKeyColumnEnabled, enabled) +} + func NewAuthNKeyIdentifyerQuery(id string) (SearchQuery, error) { return NewTextQuery(AuthNKeyColumnIdentifier, id, TextEquals) } @@ -291,8 +313,19 @@ func NewAuthNKeyCreationDateQuery(ts time.Time, compare TimestampComparison) (Se return NewTimestampQuery(AuthNKeyColumnCreationDate, ts, compare) } -func NewAuthNKeyExpirationDateDateQuery(ts time.Time, compare TimestampComparison) (SearchQuery, error) { - return NewTimestampQuery(AuthNKeyColumnExpiration, ts, compare) +func NewAuthNKeyExpirationDateQuery(ts time.Time, compare TimestampComparison) (SearchQuery, error) { + query, err := NewTimestampQuery(AuthNKeyColumnExpiration, ts, compare) + if err != nil { + return nil, err + } + if compare != TimestampGreater && compare != TimestampGreaterOrEquals { + return query, nil + } + noExpiration, err := NewIsNullQuery(AuthNKeyColumnExpiration) + if err != nil { + return nil, err + } + return NewOrQuery(query, noExpiration) } //go:embed authn_key_user.sql @@ -344,6 +377,9 @@ func prepareAuthNKeysQuery() (sq.SelectBuilder, func(rows *sql.Rows) (*AuthNKeys AuthNKeyColumnExpiration.identifier(), AuthNKeyColumnType.identifier(), AuthNKeyColumnObjectID.identifier(), + AuthNKeyColumnEnabled.identifier(), + AuthNKeyColumnPublicKey.identifier(), + AuthNKeyColumnFingerprint.identifier(), countColumn.identifier(), ).From(authNKeyTable.identifier()). PlaceholderFormat(sq.Dollar) @@ -352,6 +388,8 @@ func prepareAuthNKeysQuery() (sq.SelectBuilder, func(rows *sql.Rows) (*AuthNKeys authNKeys := make([]*AuthNKey, 0) var count uint64 for rows.Next() { + var expiration sql.NullTime + var fingerprint sql.NullString authNKey := new(AuthNKey) err := rows.Scan( &authNKey.ID, @@ -360,14 +398,23 @@ func prepareAuthNKeysQuery() (sq.SelectBuilder, func(rows *sql.Rows) (*AuthNKeys &authNKey.ChangeDate, &authNKey.ResourceOwner, &authNKey.Sequence, - &authNKey.Expiration, + &expiration, &authNKey.Type, &authNKey.ApplicationID, + &authNKey.Enabled, + &authNKey.PublicKey, + &fingerprint, &count, ) if err != nil { return nil, err } + if expiration.Valid { + authNKey.Expiration = expiration.Time + } + if fingerprint.Valid { + authNKey.Fingerprint = fingerprint.String + } authNKeys = append(authNKeys, authNKey) } diff --git a/internal/query/authn_key_test.go b/internal/query/authn_key_test.go index 619ffaac8..4edf90b08 100644 --- a/internal/query/authn_key_test.go +++ b/internal/query/authn_key_test.go @@ -7,6 +7,7 @@ import ( "fmt" "regexp" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -27,6 +28,9 @@ var ( ` projections.authn_keys2.expiration,` + ` projections.authn_keys2.type,` + ` projections.authn_keys2.object_id,` + + ` projections.authn_keys2.enabled,` + + ` projections.authn_keys2.public_key,` + + ` projections.authn_keys2.fingerprint,` + ` COUNT(*) OVER ()` + ` FROM projections.authn_keys2` prepareAuthNKeysCols = []string{ @@ -39,6 +43,9 @@ var ( "expiration", "type", "object_id", + "enabled", + "public_key", + "fingerprint", "count", } @@ -132,6 +139,9 @@ func Test_AuthNKeyPrepares(t *testing.T) { testNow, 1, "app1", + true, + []byte("public"), + "fingerprint", }, }, ), @@ -151,6 +161,9 @@ func Test_AuthNKeyPrepares(t *testing.T) { Expiration: testNow, Type: domain.AuthNKeyTypeJSON, ApplicationID: "app1", + Enabled: true, + PublicKey: []byte("public"), + Fingerprint: "fingerprint", }, }, }, @@ -173,6 +186,9 @@ func Test_AuthNKeyPrepares(t *testing.T) { testNow, 1, "app1", + true, + []byte("public1"), + "fingerprint1", }, { "id-2", @@ -181,9 +197,12 @@ func Test_AuthNKeyPrepares(t *testing.T) { testNow, "ro", uint64(20211109), - testNow, + nil, 1, "app1", + true, + []byte("public2"), + nil, }, }, ), @@ -203,6 +222,9 @@ func Test_AuthNKeyPrepares(t *testing.T) { Expiration: testNow, Type: domain.AuthNKeyTypeJSON, ApplicationID: "app1", + Enabled: true, + PublicKey: []byte("public1"), + Fingerprint: "fingerprint1", }, { ID: "id-2", @@ -211,9 +233,12 @@ func Test_AuthNKeyPrepares(t *testing.T) { ChangeDate: testNow, ResourceOwner: "ro", Sequence: 20211109, - Expiration: testNow, + Expiration: time.Time{}, Type: domain.AuthNKeyTypeJSON, ApplicationID: "app1", + Enabled: true, + PublicKey: []byte("public2"), + Fingerprint: "", }, }, }, diff --git a/internal/query/instance_by_domain.sql b/internal/query/instance_by_domain.sql index 12d82040e..e15728ee2 100644 --- a/internal/query/instance_by_domain.sql +++ b/internal/query/instance_by_domain.sql @@ -35,7 +35,10 @@ with domain as ( 'endpoint', t.endpoint, 'timeout', t.timeout, 'interrupt_on_error', t.interrupt_on_error, - 'signing_key', t.signing_key + 'signing_key', t.signing_key, + 'payload_type', t.payload_type, + 'encryption_key', encode(k.public_key, 'base64'), + 'encryption_key_id', k.id ) as execution_targets from domain d join projections.executions1 e @@ -46,6 +49,11 @@ with domain as ( join projections.targets2 t on et.instance_id = t.instance_id and et.target_id = t.id + left join projections.authn_keys2 k + on k.instance_id = et.instance_id + and k.object_id = t.id + and k.enabled = true + and (k.expiration IS NULL or k.expiration > now()) order by et.position asc ) as x group by instance_id diff --git a/internal/query/instance_by_id.sql b/internal/query/instance_by_id.sql index 58bc92cac..66bc94bf4 100644 --- a/internal/query/instance_by_id.sql +++ b/internal/query/instance_by_id.sql @@ -26,7 +26,10 @@ with features as ( 'endpoint', t.endpoint, 'timeout', t.timeout, 'interrupt_on_error', t.interrupt_on_error, - 'signing_key', t.signing_key + 'signing_key', t.signing_key, + 'payload_type', t.payload_type, + 'encryption_key', encode(k.public_key, 'base64'), + 'encryption_key_id', k.id ) as execution_targets from projections.executions1 e join projections.executions1_targets et @@ -35,6 +38,11 @@ with features as ( join projections.targets2 t on et.instance_id = t.instance_id and et.target_id = t.id + left join projections.authn_keys2 k + on k.instance_id = et.instance_id + and k.object_id = t.id + and k.enabled = true + and (k.expiration IS NULL or k.expiration > now()) where e.instance_id = $1 order by et.position asc ) as x diff --git a/internal/query/projection/authn_key.go b/internal/query/projection/authn_key.go index a287701cf..6b5b57cfd 100644 --- a/internal/query/projection/authn_key.go +++ b/internal/query/projection/authn_key.go @@ -2,6 +2,7 @@ package projection import ( "context" + "database/sql" "time" "github.com/zitadel/zitadel/internal/domain" @@ -11,6 +12,7 @@ import ( "github.com/zitadel/zitadel/internal/repository/instance" "github.com/zitadel/zitadel/internal/repository/org" "github.com/zitadel/zitadel/internal/repository/project" + "github.com/zitadel/zitadel/internal/repository/target" "github.com/zitadel/zitadel/internal/repository/user" "github.com/zitadel/zitadel/internal/zerrors" ) @@ -30,6 +32,7 @@ const ( AuthNKeyPublicKeyCol = "public_key" AuthNKeyTypeCol = "type" AuthNKeyEnabledCol = "enabled" + AuthNKeyFingerprintCol = "fingerprint" ) type authNKeyProjection struct{} @@ -53,11 +56,12 @@ func (*authNKeyProjection) Init() *old_handler.Check { handler.NewColumn(AuthNKeyAggregateIDCol, handler.ColumnTypeText), handler.NewColumn(AuthNKeySequenceCol, handler.ColumnTypeInt64), handler.NewColumn(AuthNKeyObjectIDCol, handler.ColumnTypeText), - handler.NewColumn(AuthNKeyExpirationCol, handler.ColumnTypeTimestamp), + handler.NewColumn(AuthNKeyExpirationCol, handler.ColumnTypeTimestamp, handler.Nullable()), handler.NewColumn(AuthNKeyIdentifierCol, handler.ColumnTypeText), handler.NewColumn(AuthNKeyPublicKeyCol, handler.ColumnTypeBytes), handler.NewColumn(AuthNKeyEnabledCol, handler.ColumnTypeBool, handler.Default(true)), handler.NewColumn(AuthNKeyTypeCol, handler.ColumnTypeEnum, handler.Default(0)), + handler.NewColumn(AuthNKeyFingerprintCol, handler.ColumnTypeText, handler.Nullable()), }, handler.NewPrimaryKey(AuthNKeyInstanceIDCol, AuthNKeyIDCol), handler.WithIndex(handler.NewIndex("enabled", []string{AuthNKeyEnabledCol})), @@ -117,6 +121,31 @@ func (p *authNKeyProjection) Reducers() []handler.AggregateReducer { }, }, }, + { + Aggregate: target.AggregateType, + EventReducers: []handler.EventReducer{ + { + Event: target.KeyAddedEventType, + Reduce: p.reduceAuthNKeyAdded, + }, + { + Event: target.KeyActivatedEventType, + Reduce: p.reduceTargetKeyActivated, + }, + { + Event: target.KeyDeactivatedEventType, + Reduce: p.reduceTargetKeyDeactivated, + }, + { + Event: target.KeyRemovedEventType, + Reduce: p.reduceAuthNKeyRemoved, + }, + { + Event: target.RemovedEventType, + Reduce: p.reduceAuthNKeyRemoved, + }, + }, + }, { Aggregate: org.AggregateType, EventReducers: []handler.EventReducer{ @@ -141,30 +170,43 @@ func (p *authNKeyProjection) Reducers() []handler.AggregateReducer { func (p *authNKeyProjection) reduceAuthNKeyAdded(event eventstore.Event) (*handler.Statement, error) { var authNKeyEvent struct { eventstore.BaseEvent - keyID string - objectID string - expiration time.Time - identifier string - publicKey []byte - keyType domain.AuthNKeyType + keyID string + objectID string + expiration sql.NullTime + identifier string + publicKey []byte + keyType domain.AuthNKeyType + fingerprint sql.NullString + enabled bool } switch e := event.(type) { case *project.ApplicationKeyAddedEvent: authNKeyEvent.BaseEvent = e.BaseEvent authNKeyEvent.keyID = e.KeyID authNKeyEvent.objectID = e.AppID - authNKeyEvent.expiration = e.ExpirationDate + authNKeyEvent.expiration = sql.NullTime{Time: e.ExpirationDate, Valid: true} authNKeyEvent.identifier = e.ClientID authNKeyEvent.publicKey = e.PublicKey authNKeyEvent.keyType = e.KeyType + authNKeyEvent.enabled = true case *user.MachineKeyAddedEvent: authNKeyEvent.BaseEvent = e.BaseEvent authNKeyEvent.keyID = e.KeyID authNKeyEvent.objectID = e.Aggregate().ID - authNKeyEvent.expiration = e.ExpirationDate + authNKeyEvent.expiration = sql.NullTime{Time: e.ExpirationDate, Valid: true} authNKeyEvent.identifier = e.Aggregate().ID authNKeyEvent.publicKey = e.PublicKey authNKeyEvent.keyType = e.KeyType + authNKeyEvent.enabled = true + case *target.KeyAddedEvent: + authNKeyEvent.BaseEvent = e.BaseEvent + authNKeyEvent.keyID = e.KeyID + authNKeyEvent.objectID = e.Aggregate().ID + authNKeyEvent.expiration = sql.NullTime{Time: e.ExpirationDate, Valid: !e.ExpirationDate.IsZero()} + authNKeyEvent.identifier = e.Aggregate().ID + authNKeyEvent.publicKey = e.PublicKey + authNKeyEvent.fingerprint = sql.NullString{String: e.Fingerprint, Valid: true} + authNKeyEvent.enabled = false default: return nil, zerrors.ThrowInvalidArgumentf(nil, "PROJE-Dgb32", "reduce.wrong.event.type %v", []eventstore.EventType{project.ApplicationKeyAddedEventType, user.MachineKeyAddedEventType}) } @@ -183,6 +225,8 @@ func (p *authNKeyProjection) reduceAuthNKeyAdded(event eventstore.Event) (*handl handler.NewCol(AuthNKeyIdentifierCol, authNKeyEvent.identifier), handler.NewCol(AuthNKeyPublicKeyCol, authNKeyEvent.publicKey), handler.NewCol(AuthNKeyTypeCol, authNKeyEvent.keyType), + handler.NewCol(AuthNKeyFingerprintCol, authNKeyEvent.fingerprint), + handler.NewCol(AuthNKeyEnabledCol, authNKeyEvent.enabled), }, ), nil } @@ -239,6 +283,10 @@ func (p *authNKeyProjection) reduceAuthNKeyRemoved(event eventstore.Event) (*han condition = handler.NewCond(AuthNKeyIDCol, e.KeyID) case *user.UserRemovedEvent: condition = handler.NewCond(AuthNKeyAggregateIDCol, e.Aggregate().ID) + case *target.KeyRemovedEvent: + condition = handler.NewCond(AuthNKeyIDCol, e.KeyID) + case *target.RemovedEvent: + condition = handler.NewCond(AuthNKeyAggregateIDCol, e.Aggregate().ID) default: return nil, zerrors.ThrowInvalidArgumentf(nil, "PROJE-BGge42", "reduce.wrong.event.type %v", []eventstore.EventType{project.ApplicationKeyRemovedEventType, project.ApplicationRemovedType, project.ProjectRemovedType, user.MachineKeyRemovedEventType, user.UserRemovedType}) } @@ -265,3 +313,55 @@ func (p *authNKeyProjection) reduceOwnerRemoved(event eventstore.Event) (*handle }, ), nil } + +func (p *authNKeyProjection) reduceTargetKeyActivated(event eventstore.Event) (*handler.Statement, error) { + e, err := assertEvent[*target.KeyActivatedEvent](event) + if err != nil { + return nil, err + } + return handler.NewMultiStatement( + e, + handler.AddUpdateStatement( + []handler.Column{ + handler.NewCol(AuthNKeyEnabledCol, false), + handler.NewCol(AuthNKeyChangeDateCol, e.CreationDate()), + handler.NewCol(AuthNKeySequenceCol, e.Sequence()), + }, + []handler.Condition{ + handler.NewCond(AuthNKeyInstanceIDCol, e.Aggregate().InstanceID), + handler.NewCond(AuthNKeyAggregateIDCol, e.Aggregate().ID), + handler.NewCond(AuthNKeyEnabledCol, true), + }, + ), + handler.AddUpdateStatement( + []handler.Column{ + handler.NewCol(AuthNKeyEnabledCol, true), + handler.NewCol(AuthNKeyChangeDateCol, e.CreationDate()), + handler.NewCol(AuthNKeySequenceCol, e.Sequence()), + }, + []handler.Condition{ + handler.NewCond(AuthNKeyIDCol, e.KeyID), + handler.NewCond(AuthNKeyInstanceIDCol, e.Aggregate().InstanceID), + }, + ), + ), nil +} + +func (p *authNKeyProjection) reduceTargetKeyDeactivated(event eventstore.Event) (*handler.Statement, error) { + e, err := assertEvent[*target.KeyDeactivatedEvent](event) + if err != nil { + return nil, err + } + return handler.NewUpdateStatement( + e, + []handler.Column{ + handler.NewCol(AuthNKeyEnabledCol, false), + handler.NewCol(AuthNKeyChangeDateCol, e.CreationDate()), + handler.NewCol(AuthNKeySequenceCol, e.Sequence()), + }, + []handler.Condition{ + handler.NewCond(AuthNKeyIDCol, e.KeyID), + handler.NewCond(AuthNKeyInstanceIDCol, e.Aggregate().InstanceID), + }, + ), nil +} diff --git a/internal/query/projection/authn_key_test.go b/internal/query/projection/authn_key_test.go index 81879995c..cb5f8361f 100644 --- a/internal/query/projection/authn_key_test.go +++ b/internal/query/projection/authn_key_test.go @@ -1,6 +1,7 @@ package projection import ( + "database/sql" "testing" "github.com/zitadel/zitadel/internal/domain" @@ -9,6 +10,7 @@ import ( "github.com/zitadel/zitadel/internal/repository/instance" "github.com/zitadel/zitadel/internal/repository/org" "github.com/zitadel/zitadel/internal/repository/project" + "github.com/zitadel/zitadel/internal/repository/target" "github.com/zitadel/zitadel/internal/repository/user" "github.com/zitadel/zitadel/internal/zerrors" ) @@ -40,7 +42,7 @@ func TestAuthNKeyProjection_reduces(t *testing.T) { executer: &testExecuter{ executions: []execution{ { - expectedStmt: "INSERT INTO projections.authn_keys2 (id, creation_date, change_date, resource_owner, instance_id, aggregate_id, sequence, object_id, expiration, identifier, public_key, type) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)", + expectedStmt: "INSERT INTO projections.authn_keys2 (id, creation_date, change_date, resource_owner, instance_id, aggregate_id, sequence, object_id, expiration, identifier, public_key, type, fingerprint, enabled) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)", expectedArgs: []interface{}{ "keyId", anyArg{}, @@ -54,6 +56,8 @@ func TestAuthNKeyProjection_reduces(t *testing.T) { "clientId", []byte("publicKey"), domain.AuthNKeyTypeJSON, + sql.NullString{}, // fingerprint is not set for apps + true, // apps keys are enabled by default }, }, }, @@ -77,7 +81,7 @@ func TestAuthNKeyProjection_reduces(t *testing.T) { executer: &testExecuter{ executions: []execution{ { - expectedStmt: "INSERT INTO projections.authn_keys2 (id, creation_date, change_date, resource_owner, instance_id, aggregate_id, sequence, object_id, expiration, identifier, public_key, type) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)", + expectedStmt: "INSERT INTO projections.authn_keys2 (id, creation_date, change_date, resource_owner, instance_id, aggregate_id, sequence, object_id, expiration, identifier, public_key, type, fingerprint, enabled) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)", expectedArgs: []interface{}{ "keyId", anyArg{}, @@ -91,6 +95,47 @@ func TestAuthNKeyProjection_reduces(t *testing.T) { "agg-id", []byte("publicKey"), domain.AuthNKeyTypeJSON, + sql.NullString{}, // fingerprint is not set for users + true, // user keys are enabled by default + }, + }, + }, + }, + }, + }, + { + name: "reduceAuthNKeyAdded target", + args: args{ + event: getEvent( + testEvent( + target.KeyAddedEventType, + target.AggregateType, + []byte(`{"keyId": "keyId", "expirationDate": "2021-11-30T15:00:00Z", "publicKey": "cHVibGljS2V5", "fingerprint": "fingerprint"}`), + ), eventstore.GenericEventMapper[target.KeyAddedEvent]), + }, + reduce: (&authNKeyProjection{}).reduceAuthNKeyAdded, + want: wantReduce{ + aggregateType: eventstore.AggregateType("target"), + sequence: 15, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "INSERT INTO projections.authn_keys2 (id, creation_date, change_date, resource_owner, instance_id, aggregate_id, sequence, object_id, expiration, identifier, public_key, type, fingerprint, enabled) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)", + expectedArgs: []interface{}{ + "keyId", + anyArg{}, + anyArg{}, + "ro-id", + "instance-id", + "agg-id", + uint64(15), + "agg-id", + anyArg{}, + "agg-id", + []byte("publicKey"), + domain.AuthNKeyTypeNONE, + sql.NullString{String: "fingerprint", Valid: true}, + false, // target keys are disabled by default }, }, }, @@ -470,6 +515,60 @@ func TestAuthNKeyProjection_reduces(t *testing.T) { }, }, }, + { + name: "reduceAuthNKeyRemoved target key removed", + args: args{ + event: getEvent( + testEvent( + target.KeyRemovedEventType, + target.AggregateType, + []byte(`{"keyId": "keyId"}`), + ), eventstore.GenericEventMapper[target.KeyRemovedEvent]), + }, + reduce: (&authNKeyProjection{}).reduceAuthNKeyRemoved, + want: wantReduce{ + aggregateType: eventstore.AggregateType("target"), + sequence: 15, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "DELETE FROM projections.authn_keys2 WHERE (id = $1) AND (instance_id = $2)", + expectedArgs: []interface{}{ + "keyId", + "instance-id", + }, + }, + }, + }, + }, + }, + { + name: "reduceAuthNKeyRemoved target removed", + args: args{ + event: getEvent( + testEvent( + target.RemovedEventType, + target.AggregateType, + []byte(`{"keyId": "keyId"}`), + ), eventstore.GenericEventMapper[target.RemovedEvent]), + }, + reduce: (&authNKeyProjection{}).reduceAuthNKeyRemoved, + want: wantReduce{ + aggregateType: eventstore.AggregateType("target"), + sequence: 15, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "DELETE FROM projections.authn_keys2 WHERE (aggregate_id = $1) AND (instance_id = $2)", + expectedArgs: []interface{}{ + "agg-id", + "instance-id", + }, + }, + }, + }, + }, + }, { name: "reduceOwnerRemoved", args: args{ @@ -497,6 +596,77 @@ func TestAuthNKeyProjection_reduces(t *testing.T) { }, }, }, + { + name: "reduceTargetKeyActivated", + args: args{ + event: getEvent( + testEvent( + target.KeyActivatedEventType, + target.AggregateType, + []byte(`{"keyId": "keyId"}`), + ), eventstore.GenericEventMapper[target.KeyActivatedEvent]), + }, + reduce: (&authNKeyProjection{}).reduceTargetKeyActivated, + want: wantReduce{ + aggregateType: eventstore.AggregateType("target"), + sequence: 15, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "UPDATE projections.authn_keys2 SET (enabled, change_date, sequence) = ($1, $2, $3) WHERE (instance_id = $4) AND (aggregate_id = $5) AND (enabled = $6)", + expectedArgs: []interface{}{ + false, + anyArg{}, + uint64(15), + "instance-id", + "agg-id", + true, + }, + }, + { + expectedStmt: "UPDATE projections.authn_keys2 SET (enabled, change_date, sequence) = ($1, $2, $3) WHERE (id = $4) AND (instance_id = $5)", + expectedArgs: []interface{}{ + true, + anyArg{}, + uint64(15), + "keyId", + "instance-id", + }, + }, + }, + }, + }, + }, + { + name: "reduceTargetKeyDeactivated", + args: args{ + event: getEvent( + testEvent( + target.KeyDeactivatedEventType, + target.AggregateType, + []byte(`{"keyId": "keyId"}`), + ), eventstore.GenericEventMapper[target.KeyDeactivatedEvent]), + }, + reduce: (&authNKeyProjection{}).reduceTargetKeyDeactivated, + want: wantReduce{ + aggregateType: eventstore.AggregateType("target"), + sequence: 15, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "UPDATE projections.authn_keys2 SET (enabled, change_date, sequence) = ($1, $2, $3) WHERE (id = $4) AND (instance_id = $5)", + expectedArgs: []interface{}{ + false, + anyArg{}, + uint64(15), + "keyId", + "instance-id", + }, + }, + }, + }, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/internal/query/projection/target.go b/internal/query/projection/target.go index acc42b960..430824a12 100644 --- a/internal/query/projection/target.go +++ b/internal/query/projection/target.go @@ -6,6 +6,7 @@ import ( "github.com/zitadel/zitadel/internal/eventstore" old_handler "github.com/zitadel/zitadel/internal/eventstore/handler" "github.com/zitadel/zitadel/internal/eventstore/handler/v2" + target_domain "github.com/zitadel/zitadel/internal/execution/target" "github.com/zitadel/zitadel/internal/repository/instance" "github.com/zitadel/zitadel/internal/repository/target" ) @@ -24,6 +25,7 @@ const ( TargetTimeoutCol = "timeout" TargetInterruptOnErrorCol = "interrupt_on_error" TargetSigningKey = "signing_key" + TargetPayloadType = "payload_type" ) type targetProjection struct{} @@ -51,6 +53,7 @@ func (*targetProjection) Init() *old_handler.Check { handler.NewColumn(TargetTimeoutCol, handler.ColumnTypeInt64), handler.NewColumn(TargetInterruptOnErrorCol, handler.ColumnTypeBool), handler.NewColumn(TargetSigningKey, handler.ColumnTypeJSONB, handler.Nullable()), + handler.NewColumn(TargetPayloadType, handler.ColumnTypeEnum, handler.Default(target_domain.PayloadTypeUnspecified)), }, handler.NewPrimaryKey(TargetInstanceIDCol, TargetIDCol), ), @@ -108,6 +111,7 @@ func (p *targetProjection) reduceTargetAdded(event eventstore.Event) (*handler.S handler.NewCol(TargetTimeoutCol, e.Timeout), handler.NewCol(TargetInterruptOnErrorCol, e.InterruptOnError), handler.NewCol(TargetSigningKey, e.SigningKey), + handler.NewCol(TargetPayloadType, e.PayloadType), }, ), nil } @@ -140,6 +144,9 @@ func (p *targetProjection) reduceTargetChanged(event eventstore.Event) (*handler if e.SigningKey != nil { values = append(values, handler.NewCol(TargetSigningKey, e.SigningKey)) } + if e.PayloadType != target_domain.PayloadTypeUnspecified { + values = append(values, handler.NewCol(TargetPayloadType, e.PayloadType)) + } return handler.NewUpdateStatement( e, values, diff --git a/internal/query/projection/target_test.go b/internal/query/projection/target_test.go index 95c6befd0..10ae243ed 100644 --- a/internal/query/projection/target_test.go +++ b/internal/query/projection/target_test.go @@ -29,7 +29,7 @@ func TestTargetProjection_reduces(t *testing.T) { testEvent( target.AddedEventType, target.AggregateType, - []byte(`{"name": "name", "targetType":0, "endpoint":"https://example.com", "timeout": 3000000000, "async": true, "interruptOnError": true, "signingKey": { "cryptoType": 0, "algorithm": "RSA-265", "keyId": "key-id" }}`), + []byte(`{"name": "name", "targetType":0, "endpoint":"https://example.com", "timeout": 3000000000, "async": true, "interruptOnError": true, "signingKey": { "cryptoType": 0, "algorithm": "RSA-265", "keyId": "key-id" }, "payloadType": 1}`), ), eventstore.GenericEventMapper[target.AddedEvent], ), @@ -41,7 +41,7 @@ func TestTargetProjection_reduces(t *testing.T) { executer: &testExecuter{ executions: []execution{ { - expectedStmt: "INSERT INTO projections.targets2 (instance_id, resource_owner, id, creation_date, change_date, sequence, name, endpoint, target_type, timeout, interrupt_on_error, signing_key) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)", + expectedStmt: "INSERT INTO projections.targets2 (instance_id, resource_owner, id, creation_date, change_date, sequence, name, endpoint, target_type, timeout, interrupt_on_error, signing_key, payload_type) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)", expectedArgs: []interface{}{ "instance-id", "ro-id", @@ -55,6 +55,7 @@ func TestTargetProjection_reduces(t *testing.T) { 3 * time.Second, true, anyArg{}, + target_domain.PayloadTypeJSON, }, }, }, @@ -68,7 +69,7 @@ func TestTargetProjection_reduces(t *testing.T) { testEvent( target.ChangedEventType, target.AggregateType, - []byte(`{"name": "name2", "targetType":0, "endpoint":"https://example.com", "timeout": 3000000000, "async": true, "interruptOnError": true, "signingKey": { "cryptoType": 0, "algorithm": "RSA-265", "keyId": "key-id" }}`), + []byte(`{"name": "name2", "targetType":0, "endpoint":"https://example.com", "timeout": 3000000000, "async": true, "interruptOnError": true, "signingKey": { "cryptoType": 0, "algorithm": "RSA-265", "keyId": "key-id" }, "payloadType": 1}`), ), eventstore.GenericEventMapper[target.ChangedEvent], ), @@ -80,7 +81,7 @@ func TestTargetProjection_reduces(t *testing.T) { executer: &testExecuter{ executions: []execution{ { - expectedStmt: "UPDATE projections.targets2 SET (change_date, sequence, resource_owner, name, target_type, endpoint, timeout, interrupt_on_error, signing_key) = ($1, $2, $3, $4, $5, $6, $7, $8, $9) WHERE (instance_id = $10) AND (id = $11)", + expectedStmt: "UPDATE projections.targets2 SET (change_date, sequence, resource_owner, name, target_type, endpoint, timeout, interrupt_on_error, signing_key, payload_type) = ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) WHERE (instance_id = $11) AND (id = $12)", expectedArgs: []interface{}{ anyArg{}, uint64(15), @@ -91,6 +92,7 @@ func TestTargetProjection_reduces(t *testing.T) { 3 * time.Second, true, anyArg{}, + target_domain.PayloadTypeJSON, "instance-id", "agg-id", }, diff --git a/internal/query/target.go b/internal/query/target.go index d5a464547..4a1d2019b 100644 --- a/internal/query/target.go +++ b/internal/query/target.go @@ -65,6 +65,10 @@ var ( name: projection.TargetSigningKey, table: targetTable, } + TargetColumnPayloadType = Column{ + name: projection.TargetPayloadType, + table: targetTable, + } ) type Targets struct { @@ -86,6 +90,7 @@ type Target struct { InterruptOnError bool signingKey *crypto.CryptoValue SigningKey string + PayloadType target_domain.PayloadType } func (t *Target) decryptSigningKey(alg crypto.EncryptionAlgorithm) error { @@ -166,6 +171,7 @@ func prepareTargetsQuery() (sq.SelectBuilder, func(rows *sql.Rows) (*Targets, er TargetColumnURL.identifier(), TargetColumnInterruptOnError.identifier(), TargetColumnSigningKey.identifier(), + TargetColumnPayloadType.identifier(), countColumn.identifier(), ).From(targetTable.identifier()). PlaceholderFormat(sq.Dollar), @@ -185,6 +191,7 @@ func prepareTargetsQuery() (sq.SelectBuilder, func(rows *sql.Rows) (*Targets, er &target.Endpoint, &target.InterruptOnError, &target.signingKey, + &target.PayloadType, &count, ) if err != nil { @@ -218,6 +225,7 @@ func prepareTargetQuery() (sq.SelectBuilder, func(row *sql.Row) (*Target, error) TargetColumnURL.identifier(), TargetColumnInterruptOnError.identifier(), TargetColumnSigningKey.identifier(), + TargetColumnPayloadType.identifier(), ).From(targetTable.identifier()). PlaceholderFormat(sq.Dollar), func(row *sql.Row) (*Target, error) { @@ -233,6 +241,7 @@ func prepareTargetQuery() (sq.SelectBuilder, func(row *sql.Row) (*Target, error) &target.Endpoint, &target.InterruptOnError, &target.signingKey, + &target.PayloadType, ) if err != nil { if errors.Is(err, sql.ErrNoRows) { diff --git a/internal/query/target_test.go b/internal/query/target_test.go index 570b42ba1..3cb3dbe83 100644 --- a/internal/query/target_test.go +++ b/internal/query/target_test.go @@ -26,6 +26,7 @@ var ( ` projections.targets2.endpoint,` + ` projections.targets2.interrupt_on_error,` + ` projections.targets2.signing_key,` + + ` projections.targets2.payload_type,` + ` COUNT(*) OVER ()` + ` FROM projections.targets2` prepareTargetsCols = []string{ @@ -39,6 +40,7 @@ var ( "endpoint", "interrupt_on_error", "signing_key", + "payload_type", "count", } @@ -51,7 +53,8 @@ var ( ` projections.targets2.timeout,` + ` projections.targets2.endpoint,` + ` projections.targets2.interrupt_on_error,` + - ` projections.targets2.signing_key` + + ` projections.targets2.signing_key,` + + ` projections.targets2.payload_type` + ` FROM projections.targets2` prepareTargetCols = []string{ "id", @@ -64,6 +67,7 @@ var ( "endpoint", "interrupt_on_error", "signing_key", + "payload_type", } ) @@ -114,6 +118,7 @@ func Test_TargetPrepares(t *testing.T) { KeyID: "encKey", Crypted: []byte("crypted"), }, + target_domain.PayloadTypeJSON, }, }, ), @@ -141,6 +146,7 @@ func Test_TargetPrepares(t *testing.T) { KeyID: "encKey", Crypted: []byte("crypted"), }, + PayloadType: target_domain.PayloadTypeJSON, }, }, }, @@ -169,6 +175,7 @@ func Test_TargetPrepares(t *testing.T) { KeyID: "encKey", Crypted: []byte("crypted"), }, + target_domain.PayloadTypeJSON, }, { "id-2", @@ -186,6 +193,7 @@ func Test_TargetPrepares(t *testing.T) { KeyID: "encKey", Crypted: []byte("crypted"), }, + target_domain.PayloadTypeJWT, }, { "id-3", @@ -203,6 +211,7 @@ func Test_TargetPrepares(t *testing.T) { KeyID: "encKey", Crypted: []byte("crypted"), }, + target_domain.PayloadTypeJWE, }, }, ), @@ -230,6 +239,7 @@ func Test_TargetPrepares(t *testing.T) { KeyID: "encKey", Crypted: []byte("crypted"), }, + PayloadType: target_domain.PayloadTypeJSON, }, { ObjectDetails: domain.ObjectDetails{ @@ -249,6 +259,7 @@ func Test_TargetPrepares(t *testing.T) { KeyID: "encKey", Crypted: []byte("crypted"), }, + PayloadType: target_domain.PayloadTypeJWT, }, { ObjectDetails: domain.ObjectDetails{ @@ -268,6 +279,7 @@ func Test_TargetPrepares(t *testing.T) { KeyID: "encKey", Crypted: []byte("crypted"), }, + PayloadType: target_domain.PayloadTypeJWE, }, }, }, @@ -330,6 +342,7 @@ func Test_TargetPrepares(t *testing.T) { KeyID: "encKey", Crypted: []byte("crypted"), }, + target_domain.PayloadTypeJSON, }, ), }, @@ -351,6 +364,7 @@ func Test_TargetPrepares(t *testing.T) { KeyID: "encKey", Crypted: []byte("crypted"), }, + PayloadType: target_domain.PayloadTypeJSON, }, }, { diff --git a/internal/repository/target/eventstore.go b/internal/repository/target/eventstore.go index a595bde8a..897b943c7 100644 --- a/internal/repository/target/eventstore.go +++ b/internal/repository/target/eventstore.go @@ -6,4 +6,8 @@ func init() { eventstore.RegisterFilterEventMapper(AggregateType, AddedEventType, eventstore.GenericEventMapper[AddedEvent]) eventstore.RegisterFilterEventMapper(AggregateType, ChangedEventType, eventstore.GenericEventMapper[ChangedEvent]) eventstore.RegisterFilterEventMapper(AggregateType, RemovedEventType, eventstore.GenericEventMapper[RemovedEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, KeyAddedEventType, eventstore.GenericEventMapper[KeyAddedEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, KeyActivatedEventType, eventstore.GenericEventMapper[KeyActivatedEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, KeyDeactivatedEventType, eventstore.GenericEventMapper[KeyDeactivatedEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, KeyRemovedEventType, eventstore.GenericEventMapper[KeyRemovedEvent]) } diff --git a/internal/repository/target/key.go b/internal/repository/target/key.go new file mode 100644 index 000000000..0afe0d158 --- /dev/null +++ b/internal/repository/target/key.go @@ -0,0 +1,122 @@ +package target + +import ( + "context" + "time" + + "github.com/zitadel/zitadel/internal/eventstore" +) + +const ( + keyEventTypePrefix eventstore.EventType = "target.key." + KeyAddedEventType = keyEventTypePrefix + "added" + KeyActivatedEventType = keyEventTypePrefix + "activated" + KeyDeactivatedEventType = keyEventTypePrefix + "deactivated" + KeyRemovedEventType = keyEventTypePrefix + "removed" +) + +type KeyAddedEvent struct { + eventstore.BaseEvent `json:"-"` + + KeyID string `json:"keyId,omitempty"` + PublicKey []byte `json:"publicKey,omitempty"` + Fingerprint string `json:"fingerprint,omitempty"` + ExpirationDate time.Time `json:"expirationDate,omitzero"` +} + +func (e *KeyAddedEvent) SetBaseEvent(b *eventstore.BaseEvent) { + e.BaseEvent = *b +} + +func (e *KeyAddedEvent) Payload() any { + return e +} + +func (e *KeyAddedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { + return nil +} + +func NewKeyAddedEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, + keyID string, + publicKey []byte, + fingerprint string, + expiration time.Time, +) *KeyAddedEvent { + return &KeyAddedEvent{ + BaseEvent: *eventstore.NewBaseEventForPush( + ctx, aggregate, KeyAddedEventType, + ), + KeyID: keyID, + PublicKey: publicKey, + Fingerprint: fingerprint, + ExpirationDate: expiration, + } +} + +type KeyActivatedEvent struct { + eventstore.BaseEvent `json:"-"` + + KeyID string `json:"keyId,omitempty"` +} + +func (e *KeyActivatedEvent) SetBaseEvent(b *eventstore.BaseEvent) { + e.BaseEvent = *b +} + +func (e *KeyActivatedEvent) Payload() any { + return e +} + +func (e *KeyActivatedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { + return nil +} + +func NewKeyActivatedEvent(ctx context.Context, aggregate *eventstore.Aggregate, id string) *KeyActivatedEvent { + return &KeyActivatedEvent{*eventstore.NewBaseEventForPush(ctx, aggregate, KeyActivatedEventType), id} +} + +type KeyDeactivatedEvent struct { + eventstore.BaseEvent `json:"-"` + + KeyID string `json:"keyId,omitempty"` +} + +func (e *KeyDeactivatedEvent) SetBaseEvent(b *eventstore.BaseEvent) { + e.BaseEvent = *b +} + +func (e *KeyDeactivatedEvent) Payload() any { + return e +} + +func (e *KeyDeactivatedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { + return nil +} + +func NewKeyDeactivatedEvent(ctx context.Context, aggregate *eventstore.Aggregate, id string) *KeyDeactivatedEvent { + return &KeyDeactivatedEvent{*eventstore.NewBaseEventForPush(ctx, aggregate, KeyDeactivatedEventType), id} +} + +type KeyRemovedEvent struct { + eventstore.BaseEvent `json:"-"` + + KeyID string `json:"keyId,omitempty"` +} + +func (e *KeyRemovedEvent) SetBaseEvent(b *eventstore.BaseEvent) { + e.BaseEvent = *b +} + +func (e *KeyRemovedEvent) Payload() any { + return e +} + +func (e *KeyRemovedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { + return nil +} + +func NewKeyRemovedEvent(ctx context.Context, aggregate *eventstore.Aggregate, id string) *KeyRemovedEvent { + return &KeyRemovedEvent{*eventstore.NewBaseEventForPush(ctx, aggregate, KeyRemovedEventType), id} +} diff --git a/internal/repository/target/target.go b/internal/repository/target/target.go index 63c727286..c777ef111 100644 --- a/internal/repository/target/target.go +++ b/internal/repository/target/target.go @@ -19,12 +19,13 @@ const ( type AddedEvent struct { eventstore.BaseEvent `json:"-"` - Name string `json:"name"` - TargetType target_domain.TargetType `json:"targetType"` - Endpoint string `json:"endpoint"` - Timeout time.Duration `json:"timeout"` - InterruptOnError bool `json:"interruptOnError"` - SigningKey *crypto.CryptoValue `json:"signingKey"` + Name string `json:"name"` + TargetType target_domain.TargetType `json:"targetType"` + Endpoint string `json:"endpoint"` + Timeout time.Duration `json:"timeout"` + InterruptOnError bool `json:"interruptOnError"` + SigningKey *crypto.CryptoValue `json:"signingKey"` + PayloadType target_domain.PayloadType `json:"payloadType"` } func (e *AddedEvent) SetBaseEvent(b *eventstore.BaseEvent) { @@ -48,12 +49,20 @@ func NewAddedEvent( timeout time.Duration, interruptOnError bool, signingKey *crypto.CryptoValue, + payloadType target_domain.PayloadType, ) *AddedEvent { return &AddedEvent{ *eventstore.NewBaseEventForPush( ctx, aggregate, AddedEventType, ), - name, targetType, endpoint, timeout, interruptOnError, signingKey} + name, + targetType, + endpoint, + timeout, + interruptOnError, + signingKey, + payloadType, + } } type ChangedEvent struct { @@ -65,6 +74,7 @@ type ChangedEvent struct { Timeout *time.Duration `json:"timeout,omitempty"` InterruptOnError *bool `json:"interruptOnError,omitempty"` SigningKey *crypto.CryptoValue `json:"signingKey,omitempty"` + PayloadType target_domain.PayloadType `json:"payloadType,omitempty"` oldName string } @@ -144,6 +154,12 @@ func ChangeSigningKey(signingKey *crypto.CryptoValue) func(event *ChangedEvent) } } +func ChangePayloadType(payloadType target_domain.PayloadType) func(event *ChangedEvent) { + return func(e *ChangedEvent) { + e.PayloadType = payloadType + } +} + type RemovedEvent struct { eventstore.BaseEvent `json:"-"` diff --git a/internal/static/i18n/bg.yaml b/internal/static/i18n/bg.yaml index 0f58feffc..b6388b51c 100644 --- a/internal/static/i18n/bg.yaml +++ b/internal/static/i18n/bg.yaml @@ -593,6 +593,10 @@ Errors: NoTimeout: Целта няма време за изчакване InvalidURL: Целта има невалиден URL адрес NotFound: Целта не е намерена + InvalidExpirationDate: Срокът за изтичане на ключа трябва да бъде в бъдещето + PublicKeyExpired: Публичният ключ на целта е изтекъл + PublicKeyActive: Не може да се изтрие активен публичен ключ на целта + InvalidPublicKey: Публичният ключ е невалиден. Трябва да е PEM-кодиран RSA или ECDSA публичен ключ в PKCS#8 формат Execution: ConditionInvalid: Условието за изпълнение е невалидно Invalid: Изпълнението е невалидно @@ -601,6 +605,7 @@ Errors: NoTargets: Няма определени цели Failed: неуспешно изпълнение ResponseIsNotValidJSON: Отговорът не е валиден JSON + MissingEncryptionKey: Липсващ ключ за шифроване UserSchema: NotEnabled: Функцията „Потребителска схема“ не е активирана Type: diff --git a/internal/static/i18n/cs.yaml b/internal/static/i18n/cs.yaml index b65efdc37..1167b738e 100644 --- a/internal/static/i18n/cs.yaml +++ b/internal/static/i18n/cs.yaml @@ -573,6 +573,10 @@ Errors: NoTimeout: Cíl nemá časový limit InvalidURL: Cíl má neplatnou adresu URL NotFound: Cíl nenalezen + InvalidExpirationDate: Datum vypršení platnosti klíče musí být v budoucnosti + PublicKeyExpired: Veřejný klíč cíle vypršel + PublicKeyActive: Nelze odstranit aktivní veřejný klíč cíle + InvalidPublicKey: Veřejný klíč je neplatný. Musí být PEM kódovaný RSA nebo ECDSA veřejný klíč ve formátu PKCS#8 Execution: ConditionInvalid: Podmínka provedení je neplatná Invalid: Provedení je neplatné @@ -581,6 +585,7 @@ Errors: NoTargets: Nejsou definovány žádné cíle Failed: Provedení se nezdařilo ResponseIsNotValidJSON: Odpověď není platný JSON + MissingEncryptionKey: Chybí klíč pro šifrování UserSchema: NotEnabled: Funkce "Uživatelské schéma" není povolena Type: diff --git a/internal/static/i18n/de.yaml b/internal/static/i18n/de.yaml index ea7b7d67d..5975c093e 100644 --- a/internal/static/i18n/de.yaml +++ b/internal/static/i18n/de.yaml @@ -575,6 +575,10 @@ Errors: NoTimeout: Ziel hat keinen Timeout InvalidURL: Ziel hat eine ungültige URL NotFound: Ziel nicht gefunden + InvalidExpirationDate: Ablaufdatum des Schlüssels muss in der Zukunft liegen + PublicKeyExpired: Öffentlicher Schlüssel des Ziels ist abgelaufen + PublicKeyActive: Aktiven öffentlichen Zielschlüssel kann nicht gelöscht werden + InvalidPublicKey: Der öffentliche Schlüssel ist ungültig. Muss ein PEM-kodierter RSA- oder ECDSA-öffentlicher Schlüssel im PKCS#8-Format sein Execution: ConditionInvalid: Die Ausführungsbedingung ist ungültig Invalid: Die Ausführung ist ungültig @@ -583,6 +587,7 @@ Errors: NoTargets: Keine Ziele definiert Failed: Ausführung fehlgeschlagen ResponseIsNotValidJSON: Antwort ist kein gültiges JSON + MissingEncryptionKey: Fehlender Verschlüsselungsschlüssel für die Ausführung UserSchema: NotEnabled: Funktion Benutzerschema ist nicht aktiviert Type: diff --git a/internal/static/i18n/en.yaml b/internal/static/i18n/en.yaml index 0d786dda7..f95c04bc5 100644 --- a/internal/static/i18n/en.yaml +++ b/internal/static/i18n/en.yaml @@ -576,6 +576,10 @@ Errors: NoTimeout: Target has no timeout InvalidURL: Target has an invalid URL NotFound: Target not found + InvalidExpirationDate: Key expiration date must be in the future + PublicKeyExpired: Target public key is expired + PublicKeyActive: Cannot delete active target public key + InvalidPublicKey: The public key is invalid. Must be a PEM encoded RSA or ECDSA public key in PKCS#8 format Execution: ConditionInvalid: Execution condition is invalid Invalid: Execution is invalid @@ -584,6 +588,7 @@ Errors: NoTargets: No targets defined Failed: Execution failed ResponseIsNotValidJSON: Response is not valid JSON + MissingEncryptionKey: No encryption key found for target UserSchema: NotEnabled: Feature "User Schema" is not enabled Type: diff --git a/internal/static/i18n/es.yaml b/internal/static/i18n/es.yaml index 2f96dff7a..b81cd792b 100644 --- a/internal/static/i18n/es.yaml +++ b/internal/static/i18n/es.yaml @@ -575,6 +575,10 @@ Errors: NoTimeout: El objetivo no tiene tiempo de espera InvalidURL: El objetivo tiene una URL no válida NotFound: El objetivo no encontrado + InvalidExpirationDate: La fecha de expiración de la clave debe estar en el futuro + PublicKeyExpired: La clave pública del destino ha expirado + PublicKeyActive: No se puede eliminar una clave pública activa del destino + InvalidPublicKey: La clave pública no es válida. Debe ser una clave pública RSA o ECDSA codificada en PEM en formato PKCS#8 Execution: ConditionInvalid: La condición de ejecución no es válida Invalid: La ejecución no es válida @@ -583,6 +587,7 @@ Errors: NoTargets: No hay objetivos definidos Failed: Ejecución fallida ResponseIsNotValidJSON: La respuesta no es un JSON válido + MissingEncryptionKey: Falta la clave de cifrado para la ejecución UserSchema: NotEnabled: La función "Esquema de usuario" no está habilitada Type: diff --git a/internal/static/i18n/fr.yaml b/internal/static/i18n/fr.yaml index f042e7bb8..3f6714399 100644 --- a/internal/static/i18n/fr.yaml +++ b/internal/static/i18n/fr.yaml @@ -575,6 +575,10 @@ Errors: NoTimeout: La cible n'a pas de délai d'attente InvalidURL: La cible a une URL non valide NotFound: La cible introuvable + InvalidExpirationDate: La date d'expiration de la clé doit être dans le futur + PublicKeyExpired: La clé publique de la cible a expiré + PublicKeyActive: Impossible de supprimer une clé publique active de la cible + InvalidPublicKey: La clé publique est invalide. Elle doit être une clé publique RSA ou ECDSA encodée PEM au format PKCS#8 Execution: ConditionInvalid: La condition d'exécution n'est pas valide Invalid: L'exécution est invalide @@ -583,6 +587,7 @@ Errors: NoTargets: Aucune cible définie Failed: Exécution échouée ResponseIsNotValidJSON: La réponse n'est pas un JSON valide + MissingEncryptionKey: Clé de chiffrement manquante pour l'exécution UserSchema: NotEnabled: La fonctionnalité "Schéma utilisateur" n'est pas activée Type: diff --git a/internal/static/i18n/hu.yaml b/internal/static/i18n/hu.yaml index 1369db042..4d3f72fd2 100644 --- a/internal/static/i18n/hu.yaml +++ b/internal/static/i18n/hu.yaml @@ -575,6 +575,10 @@ Errors: NoTimeout: A célnak nincs időkorlátja InvalidURL: A cél érvénytelen URL-t tartalmaz NotFound: Cél nem található + InvalidExpirationDate: A kulcs lejárati dátumának a jövőben kell lennie + PublicKeyExpired: A cél nyilvános kulcsa lejárt + PublicKeyActive: Nem törölhető az aktív cél nyilvános kulcs + InvalidPublicKey: A nyilvános kulcs érvénytelen. PEM-kódolt RSA vagy ECDSA nyilvános kulcsnak kell lennie PKCS#8 formátumban Execution: ConditionInvalid: Végrehajtási feltétel érvénytelen Invalid: A végrehajtás érvénytelen @@ -583,6 +587,7 @@ Errors: NoTargets: Nincsenek célok meghatározva Failed: Végrehajtás sikertelen ResponseIsNotValidJSON: Az válasz nem érvényes JSON + MissingEncryptionKey: Hiányzik a titkosítási kulcs UserSchema: NotEnabled: A "User Schema" funkció nincs engedélyezve Type: diff --git a/internal/static/i18n/id.yaml b/internal/static/i18n/id.yaml index f10ce4d3b..9d6db4c30 100644 --- a/internal/static/i18n/id.yaml +++ b/internal/static/i18n/id.yaml @@ -575,6 +575,10 @@ Errors: NoTimeout: Target tidak memiliki batas waktu InvalidURL: Target memiliki URL yang tidak valid NotFound: Sasaran tidak ditemukan + InvalidExpirationDate: Tanggal kadaluwarsa kunci harus di masa depan + PublicKeyExpired: Kunci publik target telah kedaluwarsa + PublicKeyActive: Tidak dapat menghapus kunci publik target yang aktif + InvalidPublicKey: Kunci publik tidak valid. Harus merupakan kunci publik RSA atau ECDSA yang dikodekan PEM dalam format PKCS#8 Execution: ConditionInvalid: Kondisi eksekusi tidak valid Invalid: Eksekusi tidak valid @@ -583,6 +587,7 @@ Errors: NoTargets: Tidak ada target yang ditentukan Failed: Eksekusi gagal ResponseIsNotValidJSON: Responsnya bukan JSON yang valid + MissingEncryptionKey: Kunci enkripsi hilang UserSchema: NotEnabled: Fitur "Skema Pengguna" tidak diaktifkan Type: diff --git a/internal/static/i18n/it.yaml b/internal/static/i18n/it.yaml index bdfb430ae..c7a1d34bb 100644 --- a/internal/static/i18n/it.yaml +++ b/internal/static/i18n/it.yaml @@ -575,6 +575,10 @@ Errors: NoTimeout: Il target non ha timeout InvalidURL: La destinazione ha un URL non valido NotFound: Obiettivo non trovato + InvalidExpirationDate: La data di scadenza della chiave deve essere nel futuro + PublicKeyExpired: La chiave pubblica dell'obiettivo è scaduta + PublicKeyActive: Impossibile eliminare la chiave pubblica dell'obiettivo attivo + InvalidPublicKey: La chiave pubblica non è valida. Deve essere una chiave pubblica RSA o ECDSA codificata PEM in formato PKCS#8 Execution: ConditionInvalid: La condizione di esecuzione non è valida Invalid: L'esecuzione non è valida @@ -583,6 +587,7 @@ Errors: NoTargets: Nessun obiettivo definito Failed: Esecuzione fallita ResponseIsNotValidJSON: La risposta non è un JSON valido + MissingEncryptionKey: Chiave di crittografia mancante per l'esecuzione UserSchema: NotEnabled: La funzionalità "Schema utente" non è abilitata Type: diff --git a/internal/static/i18n/ja.yaml b/internal/static/i18n/ja.yaml index 776ddb4c3..9f5a6e33e 100644 --- a/internal/static/i18n/ja.yaml +++ b/internal/static/i18n/ja.yaml @@ -576,6 +576,10 @@ Errors: NoTimeout: ターゲットにはタイムアウトがありません InvalidURL: ターゲットに無効な URL があります NotFound: ターゲットが見つかりません + InvalidExpirationDate: キーの有効期限は将来の日付でなければなりません + PublicKeyExpired: 対象の公開鍵は期限切れです + PublicKeyActive: アクティブな対象の公開鍵は削除できません + InvalidPublicKey: 公開鍵が無効です。PKCS#8形式のPEMエンコードされたRSAまたはECDSA公開鍵である必要があります Execution: ConditionInvalid: 実行条件が不正です Invalid: 実行は無効です @@ -584,6 +588,7 @@ Errors: NoTargets: ターゲットが定義されていません Failed: 実行に失敗しました ResponseIsNotValidJSON: 応答は有効な JSON ではありません + MissingEncryptionKey: 暗号化キーがありません UserSchema: NotEnabled: 機能「ユーザースキーマ」が有効になっていません Type: diff --git a/internal/static/i18n/ko.yaml b/internal/static/i18n/ko.yaml index 6b44eafba..e901e66f1 100644 --- a/internal/static/i18n/ko.yaml +++ b/internal/static/i18n/ko.yaml @@ -576,6 +576,10 @@ Errors: NoTimeout: 대상에 타임아웃이 없습니다 InvalidURL: 대상 URL이 유효하지 않습니다 NotFound: 대상을 찾을 수 없습니다 + InvalidExpirationDate: 키 만료 날짜는 미래여야 합니다 + PublicKeyExpired: 대상 공개키가 만료되었습니다 + PublicKeyActive: 활성 대상 공개키는 삭제할 수 없습니다 + InvalidPublicKey: 공개키가 유효하지 않습니다. PKCS#8 형식의 PEM 인코딩된 RSA 또는 ECDSA 공개키여야 합니다 Execution: ConditionInvalid: 실행 조건이 유효하지 않습니다 Invalid: 실행이 유효하지 않습니다 @@ -584,6 +588,7 @@ Errors: NoTargets: 정의된 대상이 없습니다 Failed: 실행 실패 ResponseIsNotValidJSON: 응답이 유효한 JSON이 아닙니다 + MissingEncryptionKey: 암호화 키가 누락되었습니다 UserSchema: NotEnabled: "\"사용자 스키마\" 기능이 활성화되지 않았습니다" Type: diff --git a/internal/static/i18n/mk.yaml b/internal/static/i18n/mk.yaml index 7c2df84e9..b8eaf9364 100644 --- a/internal/static/i18n/mk.yaml +++ b/internal/static/i18n/mk.yaml @@ -574,6 +574,10 @@ Errors: NoTimeout: Целта нема тајмаут InvalidURL: Целта има неважечка URL-адреса NotFound: Целта не е пронајдена + InvalidExpirationDate: Датумот на истекување на клучот мора да е во иднината + PublicKeyExpired: Јавниот клуч на целта е истечен + PublicKeyActive: Не може да се избрише активниот јавен клуч на целта + InvalidPublicKey: Јавниот клуч е неважечок. Мора да биде PEM-кодиран RSA или ECDSA јавен клуч во PKCS#8 формат Execution: ConditionInvalid: Условот за извршување е неважечки Invalid: Извршувањето е неважечко @@ -582,6 +586,7 @@ Errors: NoTargets: Не се дефинирани цели Failed: Извршувањето не успеа ResponseIsNotValidJSON: Одговорот не е валиден JSON + MissingEncryptionKey: Недостасува клуч за шифрирање UserSchema: NotEnabled: Функцијата „Корисничка шема“ не е овозможена Type: diff --git a/internal/static/i18n/nl.yaml b/internal/static/i18n/nl.yaml index 22cfadec4..cdcbc426a 100644 --- a/internal/static/i18n/nl.yaml +++ b/internal/static/i18n/nl.yaml @@ -575,6 +575,10 @@ Errors: NoTimeout: Doel heeft geen time-out InvalidURL: Doel heeft een ongeldige URL NotFound: Doel niet gevonden + InvalidExpirationDate: De vervaldatum van de sleutel moet in de toekomst liggen + PublicKeyExpired: Doelpublieke sleutel is verlopen + PublicKeyActive: Actieve doelpublieke sleutel kan niet worden verwijderd + InvalidPublicKey: De openbare sleutel is ongeldig. Moet een PEM-gecodeerde RSA- of ECDSA\-openbare sleutel in PKCS#8\-formaat zijn Execution: ConditionInvalid: Uitvoeringsvoorwaarde is ongeldig Invalid: Uitvoering is ongeldig @@ -583,6 +587,7 @@ Errors: NoTargets: Geen doelstellingen gedefinieerd Failed: Uitvoering mislukt ResponseIsNotValidJSON: Reactie is geen geldige JSON + MissingEncryptionKey: Ontbrekende encryptiesleutel voor uitvoering UserSchema: NotEnabled: Functie "Gebruikersschema" is niet ingeschakeld Type: diff --git a/internal/static/i18n/pl.yaml b/internal/static/i18n/pl.yaml index f4babd16b..f6a1ba456 100644 --- a/internal/static/i18n/pl.yaml +++ b/internal/static/i18n/pl.yaml @@ -575,6 +575,10 @@ Errors: NoTimeout: Cel nie ma limitu czasu InvalidURL: Cel ma nieprawidłowy adres URL NotFound: Nie znaleziono celu + InvalidExpirationDate: Data wygaśnięcia klucza musi być w przyszłości + PublicKeyExpired: Publiczny klucz docelowy wygasł + PublicKeyActive: Nie można usunąć aktywnego publicznego klucza docelowego + InvalidPublicKey: Klucz publiczny jest nieprawidłowy. Musi być to klucz publiczny RSA lub ECDSA zakodowany w PEM w formacie PKCS#8 Execution: ConditionInvalid: Warunek wykonania jest nieprawidłowy Invalid: Wykonanie jest nieprawidłowe @@ -583,6 +587,7 @@ Errors: NoTargets: Nie zdefiniowano celów Failed: Wykonanie nie powiodło się ResponseIsNotValidJSON: Odpowiedź nie jest prawidłowym JSON-em + MissingEncryptionKey: Brak klucza szyfrowania dla wykonania UserSchema: NotEnabled: Funkcja „Schemat użytkownika” nie jest włączona Type: diff --git a/internal/static/i18n/pt.yaml b/internal/static/i18n/pt.yaml index fd126da6e..b7f24b024 100644 --- a/internal/static/i18n/pt.yaml +++ b/internal/static/i18n/pt.yaml @@ -574,6 +574,10 @@ Errors: NoTimeout: O destino não tem tempo limite InvalidURL: O destino tem um URL inválido NotFound: Destino não encontrado + InvalidExpirationDate: A data de expiração da chave deve estar no futuro + PublicKeyExpired: A chave pública do destino expirou + PublicKeyActive: Não é possível apagar a chave pública ativa do destino + InvalidPublicKey: A chave pública é inválida. Deve ser uma chave pública RSA ou ECDSA codificada em PEM no formato PKCS#8 Execution: ConditionInvalid: A condição de execução é inválida Invalid: A execução é inválida @@ -582,6 +586,7 @@ Errors: NoTargets: Nenhuma meta definida Failed: Falha na execução ResponseIsNotValidJSON: A resposta não é um JSON válido + MissingEncryptionKey: Chave de criptografia ausente UserSchema: NotEnabled: O recurso "Esquema do usuário" não está habilitado Type: diff --git a/internal/static/i18n/ro.yaml b/internal/static/i18n/ro.yaml index a908809e9..791e334f9 100644 --- a/internal/static/i18n/ro.yaml +++ b/internal/static/i18n/ro.yaml @@ -571,6 +571,10 @@ Errors: NoTimeout: Ținta nu are timp de așteptare InvalidURL: Ținta are un URL invalid NotFound: Ținta nu a fost găsită + InvalidExpirationDate: Data de expirare a cheii trebuie să fie în viitor + PublicKeyExpired: Cheia publică a destinației a expirat + PublicKeyActive: Nu se poate șterge cheia publică activă a destinației + InvalidPublicKey: Cheia publică este invalidă. Trebuie să fie o cheie publică RSA sau ECDSA codificată PEM în format PKCS#8 Execution: ConditionInvalid: Condiția de execuție este invalidă Invalid: Execuția este invalidă @@ -579,6 +583,7 @@ Errors: NoTargets: Nu sunt definite ținte Failed: Execuția a eșuat ResponseIsNotValidJSON: Răspunsul nu este un JSON valid + MissingEncryptionKey: Lipsește cheia de criptare pentru execuție UserSchema: NotEnabled: Caracteristica "Schema de utilizator" nu este activată Type: diff --git a/internal/static/i18n/ru.yaml b/internal/static/i18n/ru.yaml index 23d4f8c38..84b66a527 100644 --- a/internal/static/i18n/ru.yaml +++ b/internal/static/i18n/ru.yaml @@ -564,6 +564,10 @@ Errors: NoTimeout: У цели нет тайм-аута InvalidURL: Цель имеет неверный URL-адрес NotFound: Цель не найдена + InvalidExpirationDate: Дата истечения срока действия ключа должна быть в будущем + PublicKeyExpired: Публичный ключ цели просрочен + PublicKeyActive: Невозможно удалить активный публичный ключ цели + InvalidPublicKey: Публичный ключ недействителен. Должен быть PEM-кодированный RSA или ECDSA публичный ключ в формате PKCS#8 Execution: ConditionInvalid: Недопустимое условие выполнения Invalid: Исполнение недействительно @@ -572,6 +576,7 @@ Errors: NoTargets: Цели не определены Failed: Выполнение не удалось ResponseIsNotValidJSON: Ответ не является допустимым JSON + MissingEncryptionKey: Отсутствует ключ шифрования UserSchema: NotEnabled: Функция «Пользовательская схема» не включена Type: diff --git a/internal/static/i18n/sv.yaml b/internal/static/i18n/sv.yaml index 65b41bc7f..b066cf84e 100644 --- a/internal/static/i18n/sv.yaml +++ b/internal/static/i18n/sv.yaml @@ -575,6 +575,10 @@ Errors: NoTimeout: Målet har ingen timeout InvalidURL: Målet har en ogiltig URL NotFound: Målet hittades inte + InvalidExpirationDate: Nyckelns utgångsdatum måste vara i framtiden + PublicKeyExpired: Målets publika nyckel har gått ut + PublicKeyActive: Kan inte ta bort en aktiv publik nyckel för målet + InvalidPublicKey: Den publika nyckeln är ogiltig. Måste vara en PEM-kodad RSA- eller ECDSA-publik nyckel i PKCS#8-format Execution: ConditionInvalid: Exekveringsvillkoret är ogiltigt Invalid: Exekveringen är ogiltig @@ -583,6 +587,7 @@ Errors: NoTargets: Inga mål definierade Failed: Utförande misslyckades ResponseIsNotValidJSON: Svaret är inte giltigt JSON + MissingEncryptionKey: Krypteringsnyckel saknas för exekvering UserSchema: NotEnabled: Funktionen "Användarschema" är inte aktiverad Type: diff --git a/internal/static/i18n/tr.yaml b/internal/static/i18n/tr.yaml index 9b2d42bf2..acf4087c8 100644 --- a/internal/static/i18n/tr.yaml +++ b/internal/static/i18n/tr.yaml @@ -576,6 +576,10 @@ Errors: NoTimeout: Hedefin zaman aşımı yok InvalidURL: Hedefin geçersiz URL'si var NotFound: Hedef bulunamadı + InvalidExpirationDate: Anahtarın sona erme tarihi gelecekte olmalıdır + PublicKeyExpired: Hedefin açık anahtarı süresi doldu + PublicKeyActive: Etkin hedef açık anahtarı silinemez + InvalidPublicKey: Açık anahtar geçersiz. PEM kodlu PKCS#8 formatında RSA veya ECDSA açık anahtarı olmalıdır Execution: ConditionInvalid: Yürütme koşulu geçersiz Invalid: Yürütme geçersiz @@ -584,6 +588,7 @@ Errors: NoTargets: Hedef tanımlanmamış Failed: Yürütme başarısız ResponseIsNotValidJSON: Yanıt geçerli JSON değil + MissingEncryptionKey: Şifreleme anahtarı eksik UserSchema: NotEnabled: '"User Schema" özelliği etkin değil' Type: diff --git a/internal/static/i18n/uk.yaml b/internal/static/i18n/uk.yaml index d5baa6b44..6ac0922bb 100644 --- a/internal/static/i18n/uk.yaml +++ b/internal/static/i18n/uk.yaml @@ -570,6 +570,10 @@ Errors: NoTimeout: Ціль не має часу очікування InvalidURL: Ціль має недійсний URL NotFound: Ціль не знайдена + InvalidExpirationDate: Дата закінчення терміну дії ключа має бути в майбутньому + PublicKeyExpired: Публічний ключ цілі прострочено + PublicKeyActive: Неможливо видалити активний публічний ключ цілі + InvalidPublicKey: Публічний ключ недійсний. Має бути PEM-кодований RSA або ECDSA публічний ключ у форматі PKCS#8 Execution: ConditionInvalid: Умова виконання недійсна Invalid: Виконання недійсне @@ -578,6 +582,7 @@ Errors: NoTargets: Не визначено цілей Failed: Виконання не вдалося ResponseIsNotValidJSON: Відповідь не є дійсним JSON + MissingEncryptionKey: Відсутній ключ шифрування для виконання UserSchema: NotEnabled: Функція "Схема користувача" не увімкнена Type: diff --git a/internal/static/i18n/zh.yaml b/internal/static/i18n/zh.yaml index 342b9d70a..76c85c069 100644 --- a/internal/static/i18n/zh.yaml +++ b/internal/static/i18n/zh.yaml @@ -576,6 +576,10 @@ Errors: NoTimeout: 目标没有超时 InvalidURL: 目标的 URL 无效 NotFound: 未找到目标 + InvalidExpirationDate: 密钥的到期日期必须是将来的时间 + PublicKeyExpired: 目标公钥已过期 + PublicKeyActive: 无法删除处于活动状态的目标公钥 + InvalidPublicKey: 公钥无效。必须是 PEM 编码的 RSA 或 ECDSA 公钥,格式为 PKCS#8 Execution: ConditionInvalid: 执行条件无效 Invalid: 执行无效 @@ -584,6 +588,7 @@ Errors: NoTargets: 没有定义目标 Failed: 执行失败 ResponseIsNotValidJSON: 响应不是有效的 JSON + MissingEncryptionKey: 缺少加密密钥 UserSchema: NotEnabled: 未启用“用户架构”功能 Type: diff --git a/proto/zitadel/action/v2/action_service.proto b/proto/zitadel/action/v2/action_service.proto index 03bf2c8d7..b79ae99cc 100644 --- a/proto/zitadel/action/v2/action_service.proto +++ b/proto/zitadel/action/v2/action_service.proto @@ -307,6 +307,175 @@ service ActionService { }; } + // Add Public Key + // + // Adds a public key to the target for payload encryption. + // The public key is used to encrypt the payload sent to the target when the payload type is set to `PAYLOAD_TYPE_JWE`. + // The public key must be in PEM format and be either an RSA or an EC key. + // On a successful addition, a key ID is returned which can not only be used to manage the key (activate, remove), + // but also will be used as the `kid` header in the JWE token sent to the target to indicate which key was used for encryption. + // Note that newly added keys are inactive by default. You must activate the key to use it for payload encryption. + // Providing an optional expiration date allows you to set a validity period for the key. + // After the expiration date, the key will be automatically deactivated and no longer used for payload encryption. + // Be sure to activate a new key before the current active key expires to avoid interruptions in your target executions. + // You can have multiple inactive keys for rotation purposes, but only one active key at a time. + // + // Required permission: + // - `action.target.write` + rpc AddPublicKey (AddPublicKeyRequest) returns (AddPublicKeyResponse) { + option (google.api.http) = { + post: "/v2/actions/targets/{target_id}/publickeys" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "action.target.write" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "Public key added successfully"; + }; + }; + }; + } + + // Activate Public Key + // + // Activates the public key for payload encryption. + // The public key is used to encrypt the payload sent to the target when the payload type is set to `PAYLOAD_TYPE_JWE`. + // Activating a new key will deactivate the current active key. Only one key can be active at a time. + // The active key is indicated in the `kid` header in the JWE token sent to the target. + // Activating a key that is already active is a no-op. + // + // Required permission: + // - `action.target.write` + rpc ActivatePublicKey (ActivatePublicKeyRequest) returns (ActivatePublicKeyResponse) { + option (google.api.http) = { + post: "/v2/actions/targets/{target_id}/publickeys/{key_id}/activate" + }; + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "action.target.write" + } + }; + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "Public key activated successfully"; + }; + }; + responses: { + key: "404" + value: { + description: "The key to activate does not exist."; + } + }; + }; + } + + // Deactivate Public Key + // + // Deactivates the public key for payload encryption. + // The public key will no longer be used to encrypt payloads sent to the target. + // Be aware that deactivating the active key will leave the target without an active key. + // Subsequent calls to the target with payload type `PAYLOAD_TYPE_JWE` will fail until a new key is activated. + // This endpoint can be used in break glass scenarios to quickly disable a compromised key. + // Deactivating a key that is already inactive is a no-op. + // + // Required permission: + // - `action.target.write` + rpc DeactivatePublicKey (DeactivatePublicKeyRequest) returns (DeactivatePublicKeyResponse) { + option (google.api.http) = { + post: "/v2/actions/targets/{target_id}/publickeys/{key_id}/deactivate" + }; + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "action.target.write" + } + }; + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "Public key deactivated successfully"; + }; + }; + responses: { + key: "404" + value: { + description: "The key to deactivate does not exist."; + } + }; + }; + } + + + // Remove Public Key + // + // Removes the public key from the target. This is a permanent action and can not be undone. + // Note that you can only remove inactive keys. Attempting to remove an active key will result in an error. + // For break glass scenarios, deactivate the key first and then remove it. + // Removing a non-existing key is a no-op. + // + // Required permission: + // - `action.target.write` + rpc RemovePublicKey (RemovePublicKeyRequest) returns (RemovePublicKeyResponse) { + option (google.api.http) = { + delete: "/v2/actions/targets/{target_id}/publickeys/{key_id}" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "action.target.write" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "Public key removed successfully"; + }; + }; + }; + } + + // List Public Keys + // + // Lists all public keys of a target. + // The response includes which key is active and the key's expiration dates. + // This allows you to manage key rotations and ensure that your target always has an active key for payload encryption. + // + // Required permission: + // - `action.target.read` + rpc ListPublicKeys (ListPublicKeysRequest) returns (ListPublicKeysResponse) { + option (google.api.http) = { + post: "/v2/actions/targets/{target_id}/publickeys/search" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "action.target.read" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "List of public keys retrieved successfully"; + }; + }; + }; + } + // Set Execution // // Sets an execution to call a target or include the targets of another execution. @@ -510,6 +679,20 @@ message CreateTargetRequest { } ]; + // Payload type defines how the payload is formatted and secured. + // The default is `PAYLOAD_TYPE_JSON`, which sends the payload as JSON in the body of the request. + // For integrity and authenticity a signature is included in the header `X-ZITADEL-Signature`. + // You can also choose to send the payload as a JWT (`PAYLOAD_TYPE_JWT`), which sends + // the payload as a signed JWT in the body of the request. This allows the receiver to verify + // the authenticity and integrity of the payload using the signing key. + // If you need encryption as well, you can choose `PAYLOAD_TYPE_JWE`, which sends the payload + // as an encrypted JWT in the body of the request. You can provide your own public key for encryption. + PayloadType payload_type = 7 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + default: "\"PAYLOAD_TYPE_JSON\"" + } + ]; + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_schema) = { example: "{\"name\": \"ip_allow_list\",\"restWebhook\":{\"interruptOnError\":true},\"timeout\":\"10s\",\"endpoint\":\"https://example.com/hooks/ip_check\"}"; }; @@ -630,6 +813,21 @@ message UpdateTargetRequest { } ]; + // Payload type defines how the payload is formatted and secured. + // The default is `PAYLOAD_TYPE_JSON`, which sends the payload as JSON in the body of the request. + // For integrity and authenticity a signature is included in the header `X-ZITADEL-Signature`. + // You can also choose to send the payload as a JWT (`PAYLOAD_TYPE_JWT`), which sends + // the payload as a signed JWT in the body of the request. This allows the receiver to verify + // the authenticity and integrity of the payload using the signing key. + // If you need encryption as well, you can choose `PAYLOAD_TYPE_JWE`, which sends the payload + // as an encrypted JWT in the body of the request. You can provide your own public key for encryption. + // If unspecified, the payload type will not be changed. + PayloadType payload_type = 9 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + default: "\"PAYLOAD_TYPE_JSON\"" + } + ]; + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_schema) = { example: "{\"name\": \"ip_allow_list\",\"restCall\":{\"interruptOnError\":true},\"timeout\":\"10s\",\"endpoint\":\"https://example.com/hooks/ip_check\",\"expirationSigningKey\":\"0s\"}"; }; @@ -725,6 +923,190 @@ message ListTargetsResponse { repeated Target targets = 2; } +message AddPublicKeyRequest { + // The unique identifier of the target to add the public key to. + string target_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1, + max_length: 200, + example: "\"69629026806489455\""; + } + ]; + + // The public key in PEM format. It must be either an RSA or an EC public key. + bytes public_key = 2 [ + (validate.rules).bytes = {min_len: 1, max_len: 8192}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS1cbk1JSUJJakFOQmdrcWhraUc5dzBCQVFzRkFEQV...\""; + } + ]; + + // Optional expiration date of the public key. + // After the expiration date, the key will be automatically deactivated and no longer used for payload encryption. + // Be sure to activate a new key before the current active key expires to avoid interruptions. + // If not set, the key will not expire. + optional google.protobuf.Timestamp expiration_date = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-12-18T07:50:47.492Z\""; + } + ]; +} + +message AddPublicKeyResponse { + // The KeyID is the unique identifier of the newly added public key. + // The ID is also used a `kid` in the JWT header for payload encryption. + // This allows the target to identify the correct key to use for decryption. + string key_id = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"69629032906489576\""; + } + ]; + + // CreationDate represents the timestamp of the public key addition. + google.protobuf.Timestamp creation_date = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2024-12-18T07:50:47.492Z\""; + } + ]; +} + +message ActivatePublicKeyRequest { + // TargetID is the unique identifier of the target to activate the public key for. + string target_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1, + max_length: 200, + example: "\"69629026806489455\""; + } + ]; + + // KeyID is the unique identifier of the public key to activate. + string key_id = 2 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1, + max_length: 200, + example: "\"69629032906489576\""; + } + ]; +} + +message ActivatePublicKeyResponse { + // ChangeDate is the timestamp of the public key's activation. + google.protobuf.Timestamp change_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2024-12-18T07:50:47.492Z\""; + } + ]; +} + +message DeactivatePublicKeyRequest { + // TargetID is the unique identifier of the target to deactivate the public key for. + string target_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1, + max_length: 200, + example: "\"69629026806489455\""; + } + ]; + + // KeyID is the unique identifier of the public key to deactivate. + string key_id = 2 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1, + max_length: 200, + example: "\"69629032906489576\""; + } + ]; +} + +message DeactivatePublicKeyResponse { + // ChangeDate is the timestamp of the public key's deactivation. + google.protobuf.Timestamp change_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2024-12-18T07:50:47.492Z\""; + } + ]; +} + +message RemovePublicKeyRequest { + // TargetID is the unique identifier of the target to remove the public key from. + string target_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1, + max_length: 200, + example: "\"69629026806489455\""; + } + ]; + + // KeyID is the unique identifier of the public key to remove. + string key_id = 2 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1, + max_length: 200, + example: "\"69629032906489576\""; + } + ]; +} + +message RemovePublicKeyResponse { + // DeletionDate is the timestamp of the public key removal. + google.protobuf.Timestamp deletion_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2024-12-18T07:50:47.492Z\""; + } + ]; +} + +message ListPublicKeysRequest { + // TargetID is the unique identifier of the target to list the public keys for. + string target_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1, + max_length: 200, + example: "\"69629026806489455\""; + } + ]; + + // List limitations and ordering. + optional zitadel.filter.v2.PaginationRequest pagination = 2; + + // The field the result is sorted by. The default is the creation date. + // Beware that if you change this, your result pagination might be inconsistent. + PublicKeyFieldName sorting_column = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + default: "\"EXECUTION_FIELD_NAME_CREATION_DATE\"" + } + ]; + + // Define the criteria to query for. + repeated PublicKeySearchFilter filters = 4; +} + +message ListPublicKeysResponse { + // List limitations and ordering. + zitadel.filter.v2.PaginationResponse pagination = 1; + + // List of all public keys for the target. + repeated PublicKey public_keys = 2; +} + message SetExecutionRequest { // Condition defining when the execution should be used. Condition condition = 1; diff --git a/proto/zitadel/action/v2/query.proto b/proto/zitadel/action/v2/query.proto index c54196096..e6f2b1f3e 100644 --- a/proto/zitadel/action/v2/query.proto +++ b/proto/zitadel/action/v2/query.proto @@ -109,4 +109,28 @@ enum ExecutionFieldName { EXECUTION_FIELD_NAME_ID = 1; EXECUTION_FIELD_NAME_CREATED_DATE = 2; EXECUTION_FIELD_NAME_CHANGED_DATE = 3; -} \ No newline at end of file +} + +enum PublicKeyFieldName { + // Unspecified field name. + PUBLIC_KEY_FIELD_NAME_UNSPECIFIED = 0; + // Sort by creation date. + PUBLIC_KEY_FIELD_NAME_CREATION_DATE = 1; + // Sort by change date. + PUBLIC_KEY_FIELD_NAME_CHANGE_DATE = 2; + // Sort by expiration date. + PUBLIC_KEY_FIELD_NAME_EXPIRATION_DATE = 3; +} + +message PublicKeySearchFilter { + oneof filter { + // Filter the keys by one or multiple ids. + filter.v2.InIDsFilter key_ids_filter = 1; + + // Filter the keys by their active status. + bool active_filter = 2; + + // Filter the keys by their expiration status. + filter.v2.TimestampFilter expiration_date_filter = 3; + } +} diff --git a/proto/zitadel/action/v2/target.proto b/proto/zitadel/action/v2/target.proto index 40372d663..c5b491a04 100644 --- a/proto/zitadel/action/v2/target.proto +++ b/proto/zitadel/action/v2/target.proto @@ -74,6 +74,20 @@ message Target { example: "\"98KmsU67\"" } ]; + + // Payload type defines how the payload is formatted and secured. + // The default is `PAYLOAD_TYPE_JSON`, which sends the payload as JSON in the body of the request. + // For integrity and authenticity a signature is included in the header `X-ZITADEL-Signature`. + // You can also choose to send the payload as a JWT (`PAYLOAD_TYPE_JWT`), which sends + // the payload as a signed JWT in the body of the request. This allows the receiver to verify + // the authenticity and integrity of the payload using the signing key (published on the JWKS URL). + // If you need encryption as well, you can choose `PAYLOAD_TYPE_JWE`, which sends the payload + // as an encrypted JWT in the body of the request. You can provide your own public key for encryption. + PayloadType payload_type = 11 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"PAYLOAD_TYPE_JSON\"" + } + ]; } message RESTWebhook { @@ -87,3 +101,77 @@ message RESTCall { } message RESTAsync {} + +enum PayloadType { + PAYLOAD_TYPE_UNSPECIFIED = 0; + // PAYLOAD_TYPE_JSON will send the payload as JSON in the body of the request. + // For integrity and authenticity a signature is included in the header `X-ZITADEL-Signature`. + // This is the current default, for backwards compatibility reasons. + PAYLOAD_TYPE_JSON = 1; + // PAYLOAD_TYPE_JWT will send the payload as JSON Web Token (JWT) in the body of the request. + // This allows the receiver to verify the authenticity and integrity of the payload + // using the signing key (published on the JWKS URL). + PAYLOAD_TYPE_JWT = 2; + // PAYLOAD_TYPE_JWE will send the payload as Encrypted JWT (JWE) in the body of the request. + // This allows additional security, e.g when passing sensitive information. + // You can provide your own public key for encryption. + PAYLOAD_TYPE_JWE = 3; +} + +message PublicKey { + // KeyID is the unique identifier of the public key. + // It's also used as the `kid` field in the JWE header when the payload type is set to `PAYLOAD_TYPE_JWE`. + string key_id = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"69629032906489576\""; + } + ]; + + // Active indicates whether the public key is active and used for payload encryption. + // Only one public key can be active at a time. + bool active = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "true"; + } + ]; + + // The public key in PEM format. + bytes public_key = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"-----BEGIN PUBLIC KEY-----...\""; + } + ]; + + // Fingerprint is the fingerprint of the public key. It's prefixed with the hashing algorithm used + // and base64 encoded without padding, e.g. `SHA256:STqK+Sd4qgdu+UjiwI8NBjOD6P7UqQ42EZYIdySEyTw`. + // The fingerprint can be used to easily compare the public key with other sources. + string fingerprint = 4 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"SHA256:STqK+Sd4qgdu+UjiwI8NBjOD6P7UqQ42EZYIdySEyTw\""; + } + ]; + + // ExpirationDate is the timestamp when the public key automatically expires and + // no longer will be used for payload encryption, even when active. + // Be sure to upload and activate a new public key before the expiration date + // to avoid failed executions. + google.protobuf.Timestamp expiration_date = 5 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2024-12-18T07:50:47.492Z\""; + } + ]; + + // CreationDate is the timestamp when the the public key was uploaded. + google.protobuf.Timestamp creation_date = 6 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2024-12-18T07:50:47.492Z\""; + } + ]; + + // ChangeDate is the timestamp when the public key was last changed, e.g. activated or deactivated. + google.protobuf.Timestamp change_date = 7 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2024-12-18T07:50:47.492Z\""; + } + ]; +}