Merge branch 'main' into next

This commit is contained in:
Livio Spring 2024-02-16 15:00:12 +01:00
commit d87341ec91
No known key found for this signature in database
GPG Key ID: 26BB1C2FA5952CF0
99 changed files with 8704 additions and 614 deletions

View File

@ -2,40 +2,61 @@ name: "Code Scanning"
on:
push:
branches:
branches:
- 'main'
paths-ignore:
- 'docs/**'
pull_request:
# The branches below must be a subset of the branches above
branches:
branches:
- 'main'
paths-ignore:
- 'docs/**'
jobs:
CodeQL-Build:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
language: [go,javascript]
steps:
- name: Checkout repository
uses: actions/checkout@v3
uses: actions/checkout@v4
- if: matrix.language == 'go'
name: Install Go
uses: actions/setup-go@v4
with:
go-version-file: go.mod
# node to install sass for go
- if: matrix.language == 'go'
uses: actions/setup-node@v4
- if: matrix.language == 'go'
run: |
npm install -g sass
make core_build
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
uses: github/codeql-action/init@v3
# Override language selection by uncommenting this and choosing your languages
with:
languages: go, javascript
languages: ${{ matrix.language }}
debug: true
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v2
# - name: Autobuild
# uses: github/codeql-action/autobuild@v2
# Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
# and modify them (or add more) to build your code if your project
# uses a compiled language
#- run: |
# make bootstrap
# make release
# autobuild does not work anymore
# and to be able to compile without an actual console build, we just need a placeholder in the console dist folder
- name: Autobuild
uses: github/codeql-action/autobuild@v3
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2
uses: github/codeql-action/analyze@v3

View File

@ -982,6 +982,8 @@ InternalAuthZ:
- "events.read"
- "milestones.read"
- "session.delete"
- "execution.target.write"
- "execution.target.delete"
- Role: "IAM_OWNER_VIEWER"
Permissions:
- "iam.read"

View File

@ -36,6 +36,7 @@ import (
internal_authz "github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/api/grpc/admin"
"github.com/zitadel/zitadel/internal/api/grpc/auth"
execution_v3_alpha "github.com/zitadel/zitadel/internal/api/grpc/execution/v3alpha"
"github.com/zitadel/zitadel/internal/api/grpc/management"
oidc_v2 "github.com/zitadel/zitadel/internal/api/grpc/oidc/v2"
"github.com/zitadel/zitadel/internal/api/grpc/org/v2"
@ -400,6 +401,9 @@ func startAPIs(
if err := apis.RegisterService(ctx, org.CreateServer(commands, queries, permissionCheck)); err != nil {
return err
}
if err := apis.RegisterService(ctx, execution_v3_alpha.CreateServer(commands, queries)); err != nil {
return err
}
instanceInterceptor := middleware.InstanceInterceptor(queries, config.HTTP1HostHeader, login.IgnoreInstanceEndpoints...)
assetsCache := middleware.AssetsCacheInterceptor(config.AssetStorage.Cache.MaxAge, config.AssetStorage.Cache.SharedMaxAge)
apis.RegisterHandlerOnPrefix(assets.HandlerPrefix, assets.NewHandler(commands, verifier, config.InternalAuthZ, id.SonyFlakeGenerator(), store, queries, middleware.CallDurationHandler, instanceInterceptor.Handler, assetsCache.Handler, limitingAccessInterceptor.Handle))

View File

@ -224,9 +224,11 @@ export class AppComponent implements OnDestroy {
});
this.isDarkTheme = this.themeService.isDarkTheme;
this.isDarkTheme
.pipe(takeUntil(this.destroy$))
.subscribe((dark) => this.onSetTheme(dark ? 'dark-theme' : 'light-theme'));
this.isDarkTheme.pipe(takeUntil(this.destroy$)).subscribe((dark) => {
const theme = dark ? 'dark-theme' : 'light-theme';
this.onSetTheme(theme);
this.setFavicon(theme);
});
this.translate.onLangChange.pipe(takeUntil(this.destroy$)).subscribe((language: LangChangeEvent) => {
this.document.documentElement.lang = language.lang;
@ -300,4 +302,27 @@ export class AppComponent implements OnDestroy {
}
});
}
private setFavicon(theme: string): void {
this.authService.labelpolicy.pipe(takeUntil(this.destroy$)).subscribe((lP) => {
if (theme === 'dark-theme' && lP?.iconUrlDark) {
// Check if asset url is stable, maybe it was deleted but still wasn't applied
fetch(lP.iconUrlDark).then((response) => {
if (response.ok) {
this.document.getElementById('appFavicon')?.setAttribute('href', lP.iconUrlDark);
}
});
} else if (theme === 'light-theme' && lP?.iconUrl) {
// Check if asset url is stable, maybe it was deleted but still wasn't applied
fetch(lP.iconUrl).then((response) => {
if (response.ok) {
this.document.getElementById('appFavicon')?.setAttribute('href', lP.iconUrl);
}
});
} else {
// Default Zitadel favicon
this.document.getElementById('appFavicon')?.setAttribute('href', 'favicon.ico');
}
});
}
}

View File

@ -1,49 +1,51 @@
<h1 class="title" mat-dialog-title>{{ 'KEYBOARDSHORTCUTS.TITLE' | translate }}</h1>
<div mat-dialog-content>
<div *ngIf="isNotOnSystem" class="keyboard-shortcuts-group">
<h2>{{ 'KEYBOARDSHORTCUTS.UNDERORGCONTEXT' | translate }}</h2>
<div class="keyboard-shortcuts-wrapper">
<div class="keyboard-shortcut" *ngFor="let shortcut of orgShortcuts">
<span class="keyboard-shortcut-name cnsl-secondary-text" [innerHTML]="shortcut.i18nKey | translate"></span>
<span class="fill-space"></span>
<template
*ngFor="let key of shortcut.keyboardKeys; index as i"
[ngTemplateOutlet]="actionkey"
[ngTemplateOutletContext]="{ key: key, last: i === shortcut.keyboardKeys.length - 1 }"
></template>
<div class="container">
<h1 class="title" mat-dialog-title>{{ 'KEYBOARDSHORTCUTS.TITLE' | translate }}</h1>
<div mat-dialog-content>
<div *ngIf="isNotOnSystem" class="keyboard-shortcuts-group">
<h2>{{ 'KEYBOARDSHORTCUTS.UNDERORGCONTEXT' | translate }}</h2>
<div class="keyboard-shortcuts-wrapper">
<div class="keyboard-shortcut" *ngFor="let shortcut of orgShortcuts">
<span class="keyboard-shortcut-name cnsl-secondary-text" [innerHTML]="shortcut.i18nKey | translate"></span>
<span class="fill-space"></span>
<template
*ngFor="let key of shortcut.keyboardKeys; index as i"
[ngTemplateOutlet]="actionkey"
[ngTemplateOutletContext]="{ key: key, last: i === shortcut.keyboardKeys.length - 1 }"
></template>
</div>
</div>
</div>
<div class="keyboard-shortcuts-group">
<h2>{{ 'KEYBOARDSHORTCUTS.SIDEWIDE' | translate }}</h2>
<div class="keyboard-shortcuts-wrapper">
<ng-container *ngFor="let shortcut of shortcuts">
<ng-template cnslHasRole [hasRole]="shortcut.permissions">
<div class="keyboard-shortcut">
<span class="keyboard-shortcut-name cnsl-secondary-text" [innerHTML]="shortcut.i18nKey | translate"></span>
<span class="fill-space"></span>
<template
*ngFor="let key of shortcut.keyboardKeys; index as i"
[ngTemplateOutlet]="actionkey"
[ngTemplateOutletContext]="{ key: key, last: i === shortcut.keyboardKeys.length - 1 }"
></template>
</div>
</ng-template>
</ng-container>
</div>
</div>
</div>
<div class="keyboard-shortcuts-group">
<h2>{{ 'KEYBOARDSHORTCUTS.SIDEWIDE' | translate }}</h2>
<div class="keyboard-shortcuts-wrapper">
<ng-container *ngFor="let shortcut of shortcuts">
<ng-template cnslHasRole [hasRole]="shortcut.permissions">
<div class="keyboard-shortcut">
<span class="keyboard-shortcut-name cnsl-secondary-text" [innerHTML]="shortcut.i18nKey | translate"></span>
<span class="fill-space"></span>
<template
*ngFor="let key of shortcut.keyboardKeys; index as i"
[ngTemplateOutlet]="actionkey"
[ngTemplateOutletContext]="{ key: key, last: i === shortcut.keyboardKeys.length - 1 }"
></template>
</div>
</ng-template>
</ng-container>
</div>
<div mat-dialog-actions class="action">
<button cdkFocusInitial mat-stroked-button (click)="closeDialog()">
{{ 'ACTIONS.CLOSE' | translate }}
</button>
<span class="fill-space"></span>
</div>
</div>
<div mat-dialog-actions class="action">
<button cdkFocusInitial mat-stroked-button (click)="closeDialog()">
{{ 'ACTIONS.CLOSE' | translate }}
</button>
<span class="fill-space"></span>
</div>
<ng-template #actionkey let-key="key" let-last="last">
<div class="keyboard-shortcuts-action-key">
<div class="keyboard-shortcuts-key-overlay"></div>
<span class="keyboard-shortcuts-span">{{ key }}</span>
</div>
<!-- <span *ngIf="!last" class="keyboard-shortcuts-plus cnsl-secondary-text">+</span> -->
</ng-template>
<ng-template #actionkey let-key="key" let-last="last">
<div class="keyboard-shortcuts-action-key">
<div class="keyboard-shortcuts-key-overlay"></div>
<span class="keyboard-shortcuts-span">{{ key }}</span>
</div>
<!-- <span *ngIf="!last" class="keyboard-shortcuts-plus cnsl-secondary-text">+</span> -->
</ng-template>
</div>

View File

@ -21,6 +21,7 @@
h2 {
font-size: 1rem;
margin: 0 0 1rem 0;
color: $text-color;
}
.keyboard-shortcuts-wrapper {
@ -85,21 +86,30 @@
.keyboard-shortcuts-plus {
font-size: 14px;
}
}
.title {
font-size: 1.2rem;
margin-top: 0;
.title {
font-size: 1.2rem;
margin-top: 0;
color: $text-color;
}
}
.action {
display: flex;
margin-top: 1rem;
button {
border-radius: 0.5rem;
.mat-mdc-button-persistent-ripple {
border-style: none !important;
}
}
.fill-space {
flex: 1;
}
}
.container {
padding: 1.5rem;
border-radius: 6px !important;
}

View File

@ -87,6 +87,7 @@ export class PrivateLabelingPolicyComponent implements OnInit, OnDestroy {
public refreshPreview: EventEmitter<void> = new EventEmitter();
public org!: Org.AsObject;
public InfoSectionType: any = InfoSectionType;
private iconChanged: boolean = false;
private destroy$: Subject<void> = new Subject();
public view: View = View.PREVIEW;
@ -265,6 +266,7 @@ export class PrivateLabelingPolicyComponent implements OnInit, OnDestroy {
return previewHandler(this.service.removeLabelPolicyLogo());
}
} else if (type === AssetType.ICON) {
this.iconChanged = true;
if (theme === Theme.DARK) {
return previewHandler(this.service.removeLabelPolicyIconDark());
} else if (theme === Theme.LIGHT) {
@ -300,6 +302,7 @@ export class PrivateLabelingPolicyComponent implements OnInit, OnDestroy {
return previewHandler(this.service.removeLabelPolicyLogo());
}
} else if (type === AssetType.ICON) {
this.iconChanged = true;
if (theme === Theme.DARK) {
return previewHandler(this.service.removeLabelPolicyIconDark());
} else if (theme === Theme.LIGHT) {
@ -348,6 +351,7 @@ export class PrivateLabelingPolicyComponent implements OnInit, OnDestroy {
break;
}
}
this.iconChanged = true;
}
}
}
@ -647,6 +651,10 @@ export class PrivateLabelingPolicyComponent implements OnInit, OnDestroy {
.then(() => {
this.toast.showInfo('POLICY.PRIVATELABELING.ACTIVATED', true);
setTimeout(() => {
if (this.iconChanged) {
this.iconChanged = false;
window.location.reload();
}
this.getData().then((data) => {
if (data.policy) {
this.data = data.policy;
@ -668,6 +676,10 @@ export class PrivateLabelingPolicyComponent implements OnInit, OnDestroy {
.then(() => {
this.toast.showInfo('POLICY.PRIVATELABELING.ACTIVATED', true);
setTimeout(() => {
if (this.iconChanged) {
this.iconChanged = false;
window.location.reload();
}
this.getData().then((data) => {
if (data.policy) {
this.data = data.policy;

View File

@ -9,11 +9,11 @@
.otp-btn,
.u2f-btn {
flex: 1;
border-radius: 0.5rem;
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
box-sizing: border-box;
white-space: normal;
line-height: 1.5rem;
@ -67,6 +67,10 @@
button {
margin: 0.5rem;
height: fit-content;
display: flex;
align-items: center;
justify-content: flex-start;
}
}

View File

@ -5,7 +5,7 @@
<title>ZITADEL • Console</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="favicon.ico" />
<link id="appFavicon" rel="icon" type="image/x-icon" href="favicon.ico" />
<link rel="stylesheet" href="./assets/icons/line-awesome/css/line-awesome.min.css" />
<link rel="manifest" href="manifest.webmanifest" />
<meta name="theme-color" content="#e6768b" />

View File

@ -5,4 +5,7 @@ managed:
plugins:
- plugin: buf.build/grpc-ecosystem/openapiv2
out: .artifacts/openapi
opt: allow_delete_body
opt:
- allow_delete_body
- remove_internal_comments=true
- preserve_rpc_order=true

View File

@ -0,0 +1,65 @@
---
title: Configure MockSAML as an Identity Provider in ZITADEL
sidebar_label: MockSAML
id: mocksaml
---
import GeneralConfigDescription from './_general_config_description.mdx';
import Intro from './_intro.mdx';
import CustomLoginPolicy from './_custom_login_policy.mdx';
import IDPsOverview from './_idps_overview.mdx';
import Activate from './_activate.mdx';
import TestSetup from './_test_setup.mdx';
<Intro provider="MockSAML"/>
:::warning
MockSAML is not intended for any production environment, only for test purposes
:::
## MockSAML
### Download metadata
You can either download the metadata under [https://mocksaml.com/api/saml/metadata?download=true](https://mocksaml.com/api/saml/metadata?download=true) or skip this step and
fill in the URL when creating the SAML SP in ZITADEL.
## ZITADEL configuration
### Add custom login policy
<CustomLoginPolicy/>
### Go to the IdP providers overview
<IDPsOverview templates="SAML SP"/>
### Create a new SAML ServiceProvider
The SAML SP provider template has everything you need preconfigured.
Add the metadata.xml or the URL to the metadata which are accessible by you ZITADEL instance.
All the necessary configuration is contained in the metadata which has to be exchanged by the ServiceProvider and the IdentityProvider.
<GeneralConfigDescription provider_account="SAML account" />
![SAML SP Provider](/img/guides/zitadel_saml_create_provider.png)
### Download metadata
Normally, you would need to download the ServiceProvider metadata from ZITADEL to upload to the IdentityProvider.
They are available under [https://{CUSTOMDOMAIN}/idps/{ID of the provider in ZITADEL}/saml/metadata], but this step can be skipped due to the fact that MockSAML is only for testing purposes.
### Activate IdP
<Activate/>
![Activate the SAML SP Provider](/img/guides/zitadel_activate_saml.png)
## Test the setup
<TestSetup loginscreen="your SAML SP login"/>
![SAML SP Button](/img/guides/zitadel_login_saml.png)
![SAML SP Login](/img/guides/mocksaml_login.png)

View File

@ -146,7 +146,7 @@ This request can be tested out in the following way:
**2. Scope used:** `openid email profile urn:zitadel:iam:org:project:id:{projectId}:aud urn:iam:org:project:roles urn:zitadel:iam:org:projects:roles`
:::note
:::important
In order to stay up-to-date with the latest ZITADEL standards, we recommend that you use the roles from the identifier `urn:zitadel:iam:org:project:{projectId}:roles` rather than `urn:zitadel:iam:org:project:roles`. While both identifiers are maintained for backwards compatibility, the format which includes the specific ID represents our more recent model.
:::

View File

@ -1,12 +1,21 @@
---
title: Billing
title: Settings / Billing
---
## General
In the general settings you can change your team name, notification settings and delete your team.
![Customer Portal Settings General](/img/manuals/portal/customer_portal_settings_general.png)
## Billing
In the billing page shows your configured payment methods and the invoice
![Customer Portal Billing](/img/manuals/portal/customer_portal_billing.png)
## Payment Method
### Payment Method
If you click on the "+" Button a popup will be shown with the needed fields to add a new payment method.
At the moment we provide only "Credit Card" payment
@ -38,11 +47,14 @@ Depending on your billing address we will mark the invoice as reverse charge.
## Update Billing Information
You will only need to add billing information if your instance is in the paid tier. There are two options on how to add your billing info.
You will only need to add billing information if your want to get the pro tier. There are two options on how to add your billing info.
2. Go to the billing menu and add a new payment method. You will be able to choose the added method, when upgrading the instance to the paid tier.
3. Add the billing information directly during the upgrade process.
1. Go to the billing menu and add a new payment method.
2. Add the billing information directly during the upgrade process.
## Invoices
We show all you invoices, and you are able to download them directly in the Customer Portal.
We show all you invoices, and you are able to download them directly in the Customer Portal.
![Customer Portal INvoices](/img/manuals/portal/customer_portal_invoices.png)

View File

@ -4,30 +4,34 @@ sidebar_label: Instances
---
The ZITADEL Customer Portal is used to manage all your different ZITADEL instances.
You can also manage your subscriptions, billing, newsletters and support requests.
Instances are containers for your organizations, users and projects.
A recommended setup could look like the following:
1. Instance: "Dev Environment"
2. Instance: "Test Environment"
3. Instance: "Prod Environment"
In the free subscription model you have one instance included.
To be able to add more instances please upgrade to "ZITADEL Pro".
## Overview
The overview shows all the instances that are registered for a specific customer.
You can directly see what kind of subscription the instance has and in which data region it is stored.
With a click on a instance row you get to the detail of the chosen instance.
The overview shows all the instances that are registered for your customer.
You can directly see the custom domain and data region.
With a click on an instance you get to the detail of the chosen instance.
![Instance Overview](/img/manuals/portal/customer_portal_instance_overview.png)
## New instance
Click on the new button above the instance table to create a new instance.
1. Enter the name of your new instance
2. Choose if you like to start with the free or the pay as you go tier
3. Choose your options (pay as you go)
1. Data Region: The region where your data is stored
2. Custom Domain: We generate a default domain ({instance-name}-{random-string}.zitadel.cloud), but you can choose you custom domain
3. If our basic SLA and Support is not enough, you can extend it
4. Check the summary
5. Add you payment method (pay as you go)
6. Return to Customer Portal
7. Instance created!
You will get an email to initialize your first user of the instance and to access the new created ZITADEL instance.
2. Add the credentials for your first administrator
- Username (prefilled)
- Password
3. Instance created! You can now see the details of your first instance.
:::info
Every new instance gets a generated domain of the form [instancename][randomnumber].zitadel.cloud
@ -37,77 +41,54 @@ Every new instance gets a generated domain of the form [instancename][randomnumb
## Detail
The detail shows you general information about your instance, which options you have and your usage.
The detail shows you general information about your instance, the region and your usage.
![New Instance](/img/manuals/portal/customer_portal_instance_detail.png)
### Upgrade Instance
### Upgrade to Pro
A free instance can be upgraded to a "pay as you go" instance. By upgrading your authenticated request will no longer be capped and you will be able to choose more options. To upgrade you must enter your billing information.
Your first instance is included in the free subscription.
As soon as you want to create your second instance or use a "pro" feature like choosing the region, you will have to upgrade to the Pro subscription.
To upgrade you must enter your billing information.
1. Go to the detail of your instance
2. Click "Upgrade to paid tier!" in the General Information
3. Choose the options you need (can be changed later)
1. Data Region
2. Custom Domain
3. Extended SLA
4. Add a payment method or choose an existing one
If you hit a limit from the free tier you will automatically be asked to add your credit card information and to subscribe to the pro tier.
You can also upgrade manually at any time.
1. Go to the settings tab
2. You can now see your Plan: "FREE"
3. Click "Upgrade"
4. Add the missing data
- Payment method: Credit Card Information
- Customer: At least you have to fill the country
5. Save the information
![Upgrade to Pro](/img/manuals/portal/customer_portal_upgrade_tier.png)
### Add Custom Domain
We recommend register a custom domain to access your ZITADEL instance.
The primary custom domain of your ZITADEL instance will be the issuer of the instance. All other custom domains can be used to access the instance itself
1. Browse to your instance
2. Click **Add custom domain**
3. To start the domain verification click the domain name and a dialog will appear, where you can choose between DNS or HTTP challenge methods.
4. For example, create a TXT record with your DNS provider for the used domain and click verify. ZITADEL will then proceed an check your DNS.
5. When the verification is successful you have the option to activate the domain by clicking **Set as primary**
1. Browse to the "Custom Domains" Tab
2. Click **Add**
3. Enter the domain you want and select the instance where the domain should belong to
4. In the next screen you will get all the information you will have to add to your DNS provider to verify your domain
> **_Please note:_** Do not delete the verification code, as ZITADEL Customer Portal will re-check the ownership of your domain from time to time
Be aware that it has some impacts if you change the primary domain of your instance.
1. The urls and issuer have to change in your app
2. Passwordless authentication is based on the domain, if you change it, your users will not be able to login with the registered passwordless authentication
2. Passkey authentication is based on the domain, if you change it, your users will not be able to login with the registered passkey authentication
![Add custom domain](/img/manuals/portal/portal_add_domain.png)
![Add custom domain](/img/manuals/portal/customer_portal_add_domain.png)
#### Verify Custom Domain
If you need a custom domain for your ZITADEL instance, you need to verify the domain.
As soon as you have added your custom domain you will have to verify it, by adding a CNAME record to your DNS provider.
1. Go to your DNS provider
2. Add a new CNAME record (You can find the target on the detail page of your instance)
3. After adding the CNAME you need to wait till the domain is verified (this can take some time)
You will now be able to use the added custom domain to access your ZITADEL instance
### Change Options
You can change your selected options in the detail of your instance.
This can have an impact on your instance cost.
1. Go to the detail of your instance
2. Click the edit button on the Options section
3. Choose your options
1. Extended SLA
2. Data Region
4. Save
![Edit Options](/img/manuals/portal/portal_edit_options.png)
### Downgrade Instance
If you are in the "Pay as you go tier" with your instance, you can downgrade it to the free tier.
:::caution
Be aware that this might have an impact for your users and application.
If you have registered a custom domain, it will be deleted.
The data region will be set to "Global", if you have selected something else.
:::
1. Go to the detail of your instance
2. Click "Change to free tier" in the General Information
3. You will see an overview of what happens when downgrading, click "Downgrade anyway"
4. In the popup you need to confirm by clicking "I am sure"

View File

@ -1,22 +0,0 @@
---
title: ZITADEL Notifications
sidebar_label: Notifications
---
You can subscribe to different newsletters and notifications.
## Change Notification/Newsletter settings
1. Click on your user avatar in the top right
2. In the menu that has opend you can see click on "Edit Notifications"
3. You can see all the different newsletters and notifications and can now enable or disable them
![Create user](/img/manuals/portal/customer_portal_notifications.png)
## Notifications
Onboarding: The onboarding list will provide you with some information when you first created your account
Newsletter: The newsletter will contain any news about the company, the product and what happens around. (e.g Blogs, Funding, etc)
Product News: You will get some news about the product, changes and new features
Security: Security should possibly not be disabled, we will send some security relevant information and critical issues here.

View File

@ -8,19 +8,18 @@ If you are new to ZITADEL your first action is to create your first ZITADEL inst
The ZITADEL customer Portal is used to manage all your different ZITADEL instances.
You can also manage your subscriptions, billing, newsletters and support requests.
Go to [ZITADEL Customer Portal](https://zitadel.cloud) and enter all the detail information.
As soon as you click "Let's go" you will get two initialization mails to finish your registration.
One is for your Customer Portal account and the other for your new created ZITADEL instance, verify both to be able to login to the systems.
To get started, enter the following data:
- Firstname
- Lastname
- Email
- Username
- Organization Name
Go to [ZITADEL Customer Portal](https://zitadel.com) and start by entering you email or use an existing account like Google.
![Customer Portal Landing Page](/img/manuals/portal/customer_portal_landing_page.png)
The instance you have created will automatically be in the free subscription, which already allows you to use all the features.
Sign in to [ZITADEL Customer Portal](https://zitadel.cloud), to manage all you instances.
In a second step fill out your user data like First-, Last- and Team-name.
![Customer Portal Landing Page Step 2](/img/manuals/portal/customer_portal_landing_page_2.png)
If you did start with your email instead of a social login (e.g Google) you have to fill a password for your authentication.
In that case you will get an initialization mail to verify your account.
![Customer Portal Landing Page Step 3](/img/manuals/portal/customer_portal_landing_page_3.png)
You are now registered with a free account and ready to try all the features of ZITADEL.
Sign in to [ZITADEL Customer Portal](https://zitadel.com), to manage all you instances.

View File

@ -6,7 +6,14 @@ sidebar_label: Support
:::note
We describe our [support services](/docs/legal/service-description/support-services) and information required in more detail in our legal section. Beware that not all features may be supported by your subscription and consult the [support states](/docs/support/software-release-cycles-support#support-states).
:::
In the header you can find a button for the support.
## General
We always recommend first having a look at our [documentation](/docs), [discord chat](https://zitadel.com/chat) and [GitHub repository](https://github.com/zitadel/zitadel)
![Customer Portal General Support](/img/manuals/portal/customer_portal_general_support.png)
## Support Request
Create a new support request with the following information:
- Subject

View File

@ -1,32 +1,32 @@
---
title: Customer Portal Users in ZITADEL
sidebar_label: Customer Portal Users
title: Customer Portal Administrators
sidebar_label: Customer Portal Administrators
---
Manage all your users who are allowed to access the Customer Portal.
Manage all your administrators who are allowed to access the Customer Portal.
For the moment all users with access to the Customer Portal will have the role "Admin".
![Create user](/img/manuals/portal/customer_portal_user_list.png)
![Create user](/img/manuals/portal/customer_portal_administrator_list.png)
## Add new user
## Add new administrator
1. Go to the Users tab in the ZITADEL Customer Portal
2. Click the button "Create user"
1. Go to the Administrators tab in the ZITADEL Customer Portal
2. Click the button "Create"
3. Fill in the Firstname, Lastname, Email and the username
4. Click create
The user will get a verification email, by clicking the button in the mail, he will get to the user activation screen and has to enter a password.
![Create user](/img/manuals/portal/customer_portal_create_user.png)
![Create user](/img/manuals/portal/customer_portal_add_admin.png)
## Delete user
## Delete administrator
1. Go to the Users tab in the ZITADEL Customer Portal
1. Go to the Administrators tab in the ZITADEL Customer Portal
2. Click the bin icon in the users table for the user you like to delete
3. You will get a popup, where you have to enter the login name of the user to confirm that you like to delete the user
4. Click the "delete" button
The user will be deleted and has no access to the Customer Portal anymore
![Delete user](/img/manuals/portal/customer_portal_delete_user.png)
![Delete user](/img/manuals/portal/customer_portal_delete_admin.png)

View File

@ -1,55 +0,0 @@
This guide provides a quick start on how to onboard to the ZITADEL Cloud Customer Portal, where you can manage your ZITADEL instances.
Follow all the guides in the get to know section, to create your first instance, upgrade to a paid tier and connect your first client.
## Try out ZITADEL Cloud
1. Visit [zitadel.cloud](https://zitadel.cloud/) to create your account. If you already have a ZITADEL instance sign in with your Customer Portal user.
2. Enter the data to create your organization (First name, Last name, Email, Username and Organization Name)
3. By clicking "Let's go" we will create a user for you.
4. You will receive an verification Email to verify the user for the Customer Portal
5. Use the newly create user to login to the Custom Portal
![Customer Portal Landing Page](/img/manuals/portal/customer_portal_landing_page.png)
## Login to Customer Portal
Use your Customer Portal user to login to the ZITADEL Customer Portal.
Here you can manage all your different instances, subscriptions and billing data.
1. Go to [zitadel.cloud](https://zitadel.cloud)
2. Click sign in
3. Use your ZITADEL Cloud user
Find out more about the Customer Portal [here](/guides/manage/cloud/overview).
## Create a new instance
:::note
This takes place in the [ZITADEL Cloud Customer Portal](https://zitadel.cloud)
:::
The creation and management of an instance takes place in the Customer Portal.
To manage your existing instances you need login with your Customer Portal user. Be aware that this is not the same user as in the instance itself.
1. Click
![New Instance](/img/manuals/portal/customer_portal_new_instance.gif)
## Login to your instance
:::note
This takes place in the your ZITADEL Cloud Instances Console
:::
After you have initialized your first admin user of the newly created ZITADEL instance. You can access the instance's Console, to manage all of your resources.
To login with the user you have initialized. You will find the link to access your instance in the initialization email of your user or in the detail page of your instance in the [ZITADEL Cloud](https://zitadel.cloud).
We generated a unique domain for each ZITADEL Cloud Instance that looks like this: {InstanceName}-{RandomString}.zitadel.cloud
**Customer Portal - Find Instance Domain:**
![Customer Portal - Instance Domain](/img/manuals/portal/portal_instance_detail_domain.png)
**Console - Landing Page**
![Console Landing Page](/img/console_dashboard.png)
## Manage Instance and Billing

View File

@ -289,8 +289,29 @@ module.exports = {
sidebarOptions: {
groupPathsBy: "tag",
},
}
}
},
user_schema: {
specPath: ".artifacts/openapi/zitadel/user/schema/v3alpha/user_schema_service.swagger.json",
outputDir: "docs/apis/resources/user_schema_service_v3",
sidebarOptions: {
groupPathsBy: "tag",
},
},
user_v3: {
specPath: ".artifacts/openapi/zitadel/user/v3alpha/user_service.swagger.json",
outputDir: "docs/apis/resources/user_service_v3",
sidebarOptions: {
groupPathsBy: "tag",
},
},
execution_v3: {
specPath: ".artifacts/openapi/zitadel/execution/v3alpha/execution_service.swagger.json",
outputDir: "docs/apis/resources/execution_service_v3",
sidebarOptions: {
groupPathsBy: "tag",
},
},
},
},
],
require.resolve("docusaurus-plugin-image-zoom"),

View File

@ -293,6 +293,7 @@ module.exports = {
"guides/integrate/identity-providers/migrate",
"guides/integrate/identity-providers/okta",
"guides/integrate/identity-providers/keycloak",
"guides/integrate/identity-providers/mocksaml",
"guides/integrate/identity-providers/additional-information",
],
},
@ -521,115 +522,238 @@ module.exports = {
link: {
type: "generated-index",
title: "Core Resources",
slug: "/apis/resources/",
description: "Resource based API definitions",
slug: "/apis/apis/",
description:
"ZITADEL provides multiple APIs to manage the system, instances and resources such as users, projects and more.\n" +
"\n" +
"There are multiple different versions and multiple services available:"+
"\n" +
"The resource based APIs are, as the name suggests, organized by resources such as users, session, settings and more.\n" +
"These services are the future of the ZITADEL APIS and the best way to start integrating ZITADEL.\n" +
"\n"+
"The service based APIs are organized by UseCase/Context, such as Auth API for authenticated users,"+
"Management API for organization managers, Admin API for instance managers and a System API for system managers.",
},
items: [
{
type: "category",
label: "Authenticated User",
label: "Service Based (V1)",
collapsed: false,
link: {
type: "generated-index",
title: "Auth API",
slug: "/apis/resources/auth",
title: "Service Based APIs (V1)",
slug: "/apis/services/",
description:
"The authentication API (aka Auth API) is used for all operations on the currently logged in user. The user id is taken from the sub claim in the token.",
"The service based APIs are organized by UseCase/Context, such as Auth API for authenticated users,"+
"Management API for organization managers, Admin API for instance managers and a System API for system managers.\n"+
"\n"+
"To improve the developer experience in managing the different resources, ZITADEL also offers Resource Based APIs (v2 and v3). "+
"Those APIs focus on the resources themselves. For example they offer a User Service, which will give you the possibility " +
"to search for users across multiple organizations.\n"+
"Note that the Resource Based APIs are not yet generally available. Please check the corresponding service" +
"for their state and functionality.",
},
items: require("./docs/apis/resources/auth/sidebar.js"),
items: [
{
type: "category",
label: "Authenticated User",
link: {
type: "generated-index",
title: "Auth API",
slug: "/apis/resources/auth",
description:
"The authentication API (aka Auth API) is used for all operations on the currently logged in user. The user id is taken from the sub claim in the token.",
},
items: require("./docs/apis/resources/auth/sidebar.js"),
},
{
type: "category",
label: "Organization Objects",
link: {
type: "generated-index",
title: "Management API",
slug: "/apis/resources/mgmt",
description:
"The management API is as the name states the interface where systems can mutate IAM objects like, organizations, projects, clients, users and so on if they have the necessary access rights. To identify the current organization you can send a header x-zitadel-orgid or if no header is set, the organization of the authenticated user is set.",
},
items: require("./docs/apis/resources/mgmt/sidebar.js"),
},
{
type: "category",
label: "Instance Objects",
link: {
type: "generated-index",
title: "Admin API",
slug: "/apis/resources/admin",
description:
"This API is intended to configure and manage one ZITADEL instance itself.",
},
items: require("./docs/apis/resources/admin/sidebar.js"),
},
{
type: "category",
label: "Instance Lifecycle",
link: {
type: "generated-index",
title: "System API",
slug: "/apis/resources/system",
description:
"This API is intended to manage the different ZITADEL instances within the system.\n" +
"\n" +
"Checkout the guide how to access the ZITADEL System API.",
},
items: require("./docs/apis/resources/system/sidebar.js"),
},
],
},
{
type: "category",
label: "Organization Objects",
label: "Resource Based (V2)",
collapsed: false,
link: {
type: "generated-index",
title: "Management API",
slug: "/apis/resources/mgmt",
title: "Resource Based APIs (V2)",
slug: "/apis/resources/",
description:
"The management API is as the name states the interface where systems can mutate IAM objects like, organizations, projects, clients, users and so on if they have the necessary access rights. To identify the current organization you can send a header x-zitadel-orgid or if no header is set, the organization of the authenticated user is set.",
"The resource based APIs are, as the name suggest, organized by resources such as users, session, settings and more. "+
"Check the list below to get an overview of all available resources.\n"+
"\n"+
"While the service based APIs (V1) work great for use cases in a specific context such as a single organization, " +
"it's sometime difficult to know which API to use, particularly for resources across multiple organizations. "+
"For instance, SearchUsers on an Instance level or on an Organization level.\n"+
"This is exactly where the resource based APIs come in place, e.g. with the User Service, " +
"where you're able to search all users and can provide the context (organization) yourself if needed or just search the whole instance.\n"+
"\n"+
"Note that these APIs are not yet generally available and therefore breaking changes might still occur.\n"+
"Please check the corresponding service for more information on the state and availability.",
},
items: require("./docs/apis/resources/mgmt/sidebar.js"),
items: [
{
type: "category",
label: "User Lifecycle (Beta)",
link: {
type: "generated-index",
title: "User Service API (Beta)",
slug: "/apis/resources/user_service",
description:
"This API is intended to manage users in a ZITADEL instance.\n" +
"\n" +
"This project is in beta state. It can AND will continue breaking until the services provide the same functionality as the current login.",
},
items: require("./docs/apis/resources/user_service/sidebar.js"),
},
{
type: "category",
label: "Session Lifecycle (Beta)",
link: {
type: "generated-index",
title: "Session Service API (Beta)",
slug: "/apis/resources/session_service",
description:
"This API is intended to manage sessions in a ZITADEL instance.\n" +
"\n" +
"This project is in beta state. It can AND will continue breaking until the services provide the same functionality as the current login.",
},
items: require("./docs/apis/resources/session_service/sidebar.js"),
},
{
type: "category",
label: "OIDC Lifecycle (Beta)",
link: {
type: "generated-index",
title: "OIDC Service API (Beta)",
slug: "/apis/resources/oidc_service",
description:
"Get OIDC Auth Request details and create callback URLs.\n" +
"\n" +
"This project is in beta state. It can AND will continue breaking until the services provide the same functionality as the current login.",
},
items: require("./docs/apis/resources/oidc_service/sidebar.js"),
},
{
type: "category",
label: "Settings Lifecycle (Beta)",
link: {
type: "generated-index",
title: "Settings Service API (Beta)",
slug: "/apis/resources/settings_service",
description:
"This API is intended to manage settings in a ZITADEL instance.\n" +
"\n" +
"This project is in beta state. It can AND will continue to break until the services provide the same functionality as the current login.",
},
items: require("./docs/apis/resources/settings_service/sidebar.js"),
},
]
},
{
type: "category",
label: "Instance Objects",
label: "Resource Based (V3)",
collapsed: false,
link: {
type: "generated-index",
title: "Admin API",
slug: "/apis/resources/admin",
title: "Resource Based APIs (V3)",
slug: "/apis/resources_v3/",
description:
"This API is intended to configure and manage one ZITADEL instance itself.",
"The resource based APIs are, as the name suggests, organized by resources such as users, session, settings and more.\n"+
"\n"+
"While the service based APIs (V1) work great for use cases in a specific context such as a single organization, " +
"it's sometime difficult to know which API to use, particularly for resources across multiple organizations. "+
"For instance, SearchUsers on an Instance level or on an Organization level.\n"+
"This is exactly where the resource based APIs come in place, e.g. with the User Service, " +
"where you're able to search all users and can provide the context (organization) yourself if needed or just search the whole instance.\n"+
"\n"+
"Version 3 offers more customization than the V2 resource bases APIs. You can define your own user schema "+
"to be able to manage users based on these schemas and customize various behaviors, such as manipulating "+
"inbound API calls, call webhooks on different event and more with the execution service.\n"+
"\n"+
"Note that these APIs are not yet generally available and therefore breaking changes might still occur.\n"+
"Please check the corresponding service for more information on the state and availability.",
},
items: require("./docs/apis/resources/admin/sidebar.js"),
},
{
type: "category",
label: "Instance Lifecycle",
link: {
type: "generated-index",
title: "System API",
slug: "/apis/resources/system",
description:
"This API is intended to manage the different ZITADEL instances within the system.\n" +
"\n" +
"Checkout the guide how to access the ZITADEL System API.",
},
items: require("./docs/apis/resources/system/sidebar.js"),
},
{
type: "category",
label: "User Lifecycle (Beta)",
link: {
type: "generated-index",
title: "User Service API (Beta)",
slug: "/apis/resources/user_service",
description:
"This API is intended to manage users in a ZITADEL instance.\n" +
"\n" +
"This project is in beta state. It can AND will continue breaking until the services provide the same functionality as the current login.",
},
items: require("./docs/apis/resources/user_service/sidebar.js"),
},
{
type: "category",
label: "Session Lifecycle (Beta)",
link: {
type: "generated-index",
title: "Session Service API (Beta)",
slug: "/apis/resources/session_service",
description:
"This API is intended to manage sessions in a ZITADEL instance.\n" +
"\n" +
"This project is in beta state. It can AND will continue breaking until the services provide the same functionality as the current login.",
},
items: require("./docs/apis/resources/session_service/sidebar.js"),
},
{
type: "category",
label: "OIDC Lifecycle (Beta)",
link: {
type: "generated-index",
title: "OIDC Service API (Beta)",
slug: "/apis/resources/oidc_service",
description:
"Get OIDC Auth Request details and create callback URLs.\n" +
"\n" +
"This project is in beta state. It can AND will continue breaking until the services provide the same functionality as the current login.",
},
items: require("./docs/apis/resources/oidc_service/sidebar.js"),
},
{
type: "category",
label: "Settings Lifecycle (Beta)",
link: {
type: "generated-index",
title: "Settings Service API (Beta)",
slug: "/apis/resources/settings_service",
description:
"This API is intended to manage settings in a ZITADEL instance.\n" +
"\n" +
"This project is in beta state. It can AND will continue to break until the services provide the same functionality as the current login.",
},
items: require("./docs/apis/resources/settings_service/sidebar.js"),
items: [
{
type: "category",
label: "User Schema Lifecycle (Alpha)",
link: {
type: "generated-index",
title: "User Schema Service API (Aplha)",
slug: "/apis/resources/user_schema_service",
description:
"This API is intended to manage data schemas for users in a ZITADEL instance.\n" +
"\n" +
"This project is in alpha state. It can AND will continue breaking until the service provides the same functionality as the v1 and v2 user services.",
},
items: require("./docs/apis/resources/user_schema_service_v3/sidebar.js"),
},
{
type: "category",
label: "User Lifecycle (Alpha)",
link: {
type: "generated-index",
title: "User Service API (Aplha)",
slug: "/apis/resources/user_service_v3",
description:
"This API is intended to manage users with your own data schema in a ZITADEL instance.\n"+
"\n"+
"This project is in alpha state. It can AND will continue breaking until the service provides the same functionality as the v1 and v2 user services."
},
items: require("./docs/apis/resources/user_service_v3/sidebar.js"),
},
{
type: "category",
label: "Execution Lifecycle (Alpha)",
link: {
type: "generated-index",
title: "Execution Service API (Alpha)",
slug: "/apis/resources/execution_service_v3",
description:
"This API is intended to manage custom executions (previously known as actions) in a ZITADEL instance.\n"+
"\n"+
"This project is in alpha state. It can AND will continue breaking until the services provide the same functionality as the current actions.",
},
items: require("./docs/apis/resources/execution_service_v3/sidebar.js"),
},
]
},
{
type: "category",

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 203 KiB

After

Width:  |  Height:  |  Size: 318 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 326 KiB

After

Width:  |  Height:  |  Size: 353 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 217 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 458 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 MiB

After

Width:  |  Height:  |  Size: 713 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 283 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 223 KiB

After

Width:  |  Height:  |  Size: 303 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 KiB

View File

@ -27,7 +27,7 @@ describe('organizations', () => {
cy.visit(orgsPath);
cy.contains('tr', newOrg).click();
cy.get('[data-e2e="actions"]').click();
cy.get('[data-e2e="delete"]', { timeout: 1000 }).should('be.visible').click();
cy.get('[data-e2e="delete"]', { timeout: 3000 }).should('be.visible').click();
cy.get('[data-e2e="confirm-dialog-input"]').focus().clear().type(newOrg);
cy.get('[data-e2e="confirm-dialog-button"]').click();
cy.shouldConfirmSuccess();

View File

@ -0,0 +1,51 @@
package execution
import (
"google.golang.org/grpc"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/api/grpc/server"
"github.com/zitadel/zitadel/internal/command"
"github.com/zitadel/zitadel/internal/query"
execution "github.com/zitadel/zitadel/pkg/grpc/execution/v3alpha"
)
var _ execution.ExecutionServiceServer = (*Server)(nil)
type Server struct {
execution.UnimplementedExecutionServiceServer
command *command.Commands
query *query.Queries
}
type Config struct{}
func CreateServer(
command *command.Commands,
query *query.Queries,
) *Server {
return &Server{
command: command,
query: query,
}
}
func (s *Server) RegisterServer(grpcServer *grpc.Server) {
execution.RegisterExecutionServiceServer(grpcServer, s)
}
func (s *Server) AppName() string {
return execution.ExecutionService_ServiceDesc.ServiceName
}
func (s *Server) MethodPrefix() string {
return execution.ExecutionService_ServiceDesc.ServiceName
}
func (s *Server) AuthMethods() authz.MethodMapping {
return execution.ExecutionService_AuthMethods
}
func (s *Server) RegisterGateway() server.RegisterGatewayFunc {
return execution.RegisterExecutionServiceHandler
}

View File

@ -0,0 +1,95 @@
package execution
import (
"context"
"github.com/muhlemmer/gu"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/api/grpc/object/v2"
"github.com/zitadel/zitadel/internal/command"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/eventstore/v1/models"
execution "github.com/zitadel/zitadel/pkg/grpc/execution/v3alpha"
)
func (s *Server) CreateTarget(ctx context.Context, req *execution.CreateTargetRequest) (*execution.CreateTargetResponse, error) {
add := createTargetToCommand(req)
details, err := s.command.AddTarget(ctx, add, authz.GetInstance(ctx).InstanceID())
if err != nil {
return nil, err
}
return &execution.CreateTargetResponse{
Id: add.AggregateID,
Details: object.DomainToDetailsPb(details),
}, nil
}
func (s *Server) UpdateTarget(ctx context.Context, req *execution.UpdateTargetRequest) (*execution.UpdateTargetResponse, error) {
details, err := s.command.ChangeTarget(ctx, updateTargetToCommand(req), authz.GetInstance(ctx).InstanceID())
if err != nil {
return nil, err
}
return &execution.UpdateTargetResponse{
Details: object.DomainToDetailsPb(details),
}, nil
}
func (s *Server) DeleteTarget(ctx context.Context, req *execution.DeleteTargetRequest) (*execution.DeleteTargetResponse, error) {
details, err := s.command.DeleteTarget(ctx, req.GetTargetId(), authz.GetInstance(ctx).InstanceID())
if err != nil {
return nil, err
}
return &execution.DeleteTargetResponse{
Details: object.DomainToDetailsPb(details),
}, nil
}
func createTargetToCommand(req *execution.CreateTargetRequest) *command.AddTarget {
var targetType domain.TargetType
var url string
switch t := req.GetTargetType().(type) {
case *execution.CreateTargetRequest_RestWebhook:
targetType = domain.TargetTypeWebhook
url = t.RestWebhook.GetUrl()
case *execution.CreateTargetRequest_RestRequestResponse:
targetType = domain.TargetTypeRequestResponse
url = t.RestRequestResponse.GetUrl()
}
return &command.AddTarget{
Name: req.GetName(),
TargetType: targetType,
URL: url,
Timeout: req.GetTimeout().AsDuration(),
Async: req.GetIsAsync(),
InterruptOnError: req.GetInterruptOnError(),
}
}
func updateTargetToCommand(req *execution.UpdateTargetRequest) *command.ChangeTarget {
if req == nil {
return nil
}
target := &command.ChangeTarget{
ObjectRoot: models.ObjectRoot{
AggregateID: req.GetTargetId(),
},
Name: req.Name,
}
switch t := req.GetTargetType().(type) {
case *execution.UpdateTargetRequest_RestWebhook:
target.TargetType = gu.Ptr(domain.TargetTypeWebhook)
target.URL = gu.Ptr(t.RestWebhook.GetUrl())
case *execution.UpdateTargetRequest_RestRequestResponse:
target.TargetType = gu.Ptr(domain.TargetTypeRequestResponse)
target.URL = gu.Ptr(t.RestRequestResponse.GetUrl())
}
if req.Timeout != nil {
target.Timeout = gu.Ptr(req.GetTimeout().AsDuration())
}
if req.ExecutionType != nil {
target.Async = gu.Ptr(req.GetIsAsync())
target.InterruptOnError = gu.Ptr(req.GetInterruptOnError())
}
return target
}

View File

@ -0,0 +1,411 @@
//go:build integration
package execution_test
import (
"context"
"fmt"
"os"
"testing"
"time"
"github.com/muhlemmer/gu"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/types/known/durationpb"
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/zitadel/zitadel/internal/integration"
execution "github.com/zitadel/zitadel/pkg/grpc/execution/v3alpha"
object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta"
)
var (
CTX context.Context
Tester *integration.Tester
Client execution.ExecutionServiceClient
)
func TestMain(m *testing.M) {
os.Exit(func() int {
ctx, errCtx, cancel := integration.Contexts(5 * time.Minute)
defer cancel()
Tester = integration.NewTester(ctx)
defer Tester.Done()
Client = Tester.Client.ExecutionV3
CTX, _ = Tester.WithAuthorization(ctx, integration.IAMOwner), errCtx
return m.Run()
}())
}
func TestServer_CreateTarget(t *testing.T) {
tests := []struct {
name string
ctx context.Context
req *execution.CreateTargetRequest
want *execution.CreateTargetResponse
wantErr bool
}{
{
name: "missing permission",
ctx: Tester.WithAuthorization(context.Background(), integration.OrgOwner),
req: &execution.CreateTargetRequest{
Name: fmt.Sprint(time.Now().UnixNano() + 1),
},
wantErr: true,
},
{
name: "empty name",
ctx: CTX,
req: &execution.CreateTargetRequest{
Name: "",
},
wantErr: true,
},
{
name: "empty type",
ctx: CTX,
req: &execution.CreateTargetRequest{
Name: fmt.Sprint(time.Now().UnixNano() + 1),
TargetType: nil,
},
wantErr: true,
},
{
name: "empty webhook url",
ctx: CTX,
req: &execution.CreateTargetRequest{
Name: fmt.Sprint(time.Now().UnixNano() + 1),
TargetType: &execution.CreateTargetRequest_RestWebhook{
RestWebhook: &execution.SetRESTWebhook{},
},
},
wantErr: true,
},
{
name: "empty request response url",
ctx: CTX,
req: &execution.CreateTargetRequest{
Name: fmt.Sprint(time.Now().UnixNano() + 1),
TargetType: &execution.CreateTargetRequest_RestRequestResponse{
RestRequestResponse: &execution.SetRESTRequestResponse{},
},
},
wantErr: true,
},
{
name: "empty timeout",
ctx: CTX,
req: &execution.CreateTargetRequest{
Name: fmt.Sprint(time.Now().UnixNano() + 1),
TargetType: &execution.CreateTargetRequest_RestWebhook{
RestWebhook: &execution.SetRESTWebhook{
Url: "https://example.com",
},
},
Timeout: nil,
ExecutionType: nil,
},
wantErr: true,
},
{
name: "empty execution type, ok",
ctx: CTX,
req: &execution.CreateTargetRequest{
Name: fmt.Sprint(time.Now().UnixNano() + 1),
TargetType: &execution.CreateTargetRequest_RestWebhook{
RestWebhook: &execution.SetRESTWebhook{
Url: "https://example.com",
},
},
Timeout: durationpb.New(10 * time.Second),
ExecutionType: nil,
},
want: &execution.CreateTargetResponse{
Details: &object.Details{
ChangeDate: timestamppb.Now(),
ResourceOwner: Tester.Instance.InstanceID(),
},
},
},
{
name: "async execution, ok",
ctx: CTX,
req: &execution.CreateTargetRequest{
Name: fmt.Sprint(time.Now().UnixNano() + 1),
TargetType: &execution.CreateTargetRequest_RestWebhook{
RestWebhook: &execution.SetRESTWebhook{
Url: "https://example.com",
},
},
Timeout: durationpb.New(10 * time.Second),
ExecutionType: &execution.CreateTargetRequest_IsAsync{
IsAsync: true,
},
},
want: &execution.CreateTargetResponse{
Details: &object.Details{
ChangeDate: timestamppb.Now(),
ResourceOwner: Tester.Instance.InstanceID(),
},
},
},
{
name: "interrupt on error execution, ok",
ctx: CTX,
req: &execution.CreateTargetRequest{
Name: fmt.Sprint(time.Now().UnixNano() + 1),
TargetType: &execution.CreateTargetRequest_RestWebhook{
RestWebhook: &execution.SetRESTWebhook{
Url: "https://example.com",
},
},
Timeout: durationpb.New(10 * time.Second),
ExecutionType: &execution.CreateTargetRequest_InterruptOnError{
InterruptOnError: true,
},
},
want: &execution.CreateTargetResponse{
Details: &object.Details{
ChangeDate: timestamppb.Now(),
ResourceOwner: Tester.Instance.InstanceID(),
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := Client.CreateTarget(tt.ctx, tt.req)
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
integration.AssertDetails(t, tt.want, got)
assert.NotEmpty(t, got.GetId())
})
}
}
func TestServer_UpdateTarget(t *testing.T) {
type args struct {
ctx context.Context
req *execution.UpdateTargetRequest
}
tests := []struct {
name string
prepare func(request *execution.UpdateTargetRequest) error
args args
want *execution.UpdateTargetResponse
wantErr bool
}{
{
name: "missing permission",
prepare: func(request *execution.UpdateTargetRequest) error {
targetID := Tester.CreateTarget(CTX, t).GetId()
request.TargetId = targetID
return nil
},
args: args{
ctx: Tester.WithAuthorization(context.Background(), integration.OrgOwner),
req: &execution.UpdateTargetRequest{
Name: gu.Ptr(fmt.Sprint(time.Now().UnixNano() + 1)),
},
},
wantErr: true,
},
{
name: "not existing",
prepare: func(request *execution.UpdateTargetRequest) error {
request.TargetId = "notexisting"
return nil
},
args: args{
ctx: CTX,
req: &execution.UpdateTargetRequest{
Name: gu.Ptr(fmt.Sprint(time.Now().UnixNano() + 1)),
},
},
wantErr: true,
},
{
name: "change name, ok",
prepare: func(request *execution.UpdateTargetRequest) error {
targetID := Tester.CreateTarget(CTX, t).GetId()
request.TargetId = targetID
return nil
},
args: args{
ctx: CTX,
req: &execution.UpdateTargetRequest{
Name: gu.Ptr(fmt.Sprint(time.Now().UnixNano() + 1)),
},
},
want: &execution.UpdateTargetResponse{
Details: &object.Details{
ChangeDate: timestamppb.Now(),
ResourceOwner: Tester.Instance.InstanceID(),
},
},
},
{
name: "change type, ok",
prepare: func(request *execution.UpdateTargetRequest) error {
targetID := Tester.CreateTarget(CTX, t).GetId()
request.TargetId = targetID
return nil
},
args: args{
ctx: CTX,
req: &execution.UpdateTargetRequest{
TargetType: &execution.UpdateTargetRequest_RestRequestResponse{
RestRequestResponse: &execution.SetRESTRequestResponse{
Url: "https://example.com",
},
},
},
},
want: &execution.UpdateTargetResponse{
Details: &object.Details{
ChangeDate: timestamppb.Now(),
ResourceOwner: Tester.Instance.InstanceID(),
},
},
},
{
name: "change url, ok",
prepare: func(request *execution.UpdateTargetRequest) error {
targetID := Tester.CreateTarget(CTX, t).GetId()
request.TargetId = targetID
return nil
},
args: args{
ctx: CTX,
req: &execution.UpdateTargetRequest{
TargetType: &execution.UpdateTargetRequest_RestWebhook{
RestWebhook: &execution.SetRESTWebhook{
Url: "https://example.com/hooks/new",
},
},
},
},
want: &execution.UpdateTargetResponse{
Details: &object.Details{
ChangeDate: timestamppb.Now(),
ResourceOwner: Tester.Instance.InstanceID(),
},
},
},
{
name: "change timeout, ok",
prepare: func(request *execution.UpdateTargetRequest) error {
targetID := Tester.CreateTarget(CTX, t).GetId()
request.TargetId = targetID
return nil
},
args: args{
ctx: CTX,
req: &execution.UpdateTargetRequest{
Timeout: durationpb.New(20 * time.Second),
},
},
want: &execution.UpdateTargetResponse{
Details: &object.Details{
ChangeDate: timestamppb.Now(),
ResourceOwner: Tester.Instance.InstanceID(),
},
},
},
{
name: "change execution type, ok",
prepare: func(request *execution.UpdateTargetRequest) error {
targetID := Tester.CreateTarget(CTX, t).GetId()
request.TargetId = targetID
return nil
},
args: args{
ctx: CTX,
req: &execution.UpdateTargetRequest{
ExecutionType: &execution.UpdateTargetRequest_IsAsync{
IsAsync: true,
},
},
},
want: &execution.UpdateTargetResponse{
Details: &object.Details{
ChangeDate: timestamppb.Now(),
ResourceOwner: Tester.Instance.InstanceID(),
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.prepare(tt.args.req)
require.NoError(t, err)
got, err := Client.UpdateTarget(tt.args.ctx, tt.args.req)
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
integration.AssertDetails(t, tt.want, got)
})
}
}
func TestServer_DeleteTarget(t *testing.T) {
target := Tester.CreateTarget(CTX, t)
tests := []struct {
name string
ctx context.Context
req *execution.DeleteTargetRequest
want *execution.DeleteTargetResponse
wantErr bool
}{
{
name: "missing permission",
ctx: Tester.WithAuthorization(context.Background(), integration.OrgOwner),
req: &execution.DeleteTargetRequest{
TargetId: target.GetId(),
},
wantErr: true,
},
{
name: "empty id",
ctx: CTX,
req: &execution.DeleteTargetRequest{
TargetId: "",
},
wantErr: true,
},
{
name: "delete target",
ctx: CTX,
req: &execution.DeleteTargetRequest{
TargetId: target.GetId(),
},
want: &execution.DeleteTargetResponse{
Details: &object.Details{
ChangeDate: timestamppb.Now(),
ResourceOwner: Tester.Instance.InstanceID(),
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := Client.DeleteTarget(tt.ctx, tt.req)
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
integration.AssertDetails(t, tt.want, got)
})
}
}

View File

@ -0,0 +1,193 @@
package execution
import (
"testing"
"time"
"github.com/muhlemmer/gu"
"github.com/stretchr/testify/assert"
"google.golang.org/protobuf/types/known/durationpb"
"github.com/zitadel/zitadel/internal/command"
"github.com/zitadel/zitadel/internal/domain"
execution "github.com/zitadel/zitadel/pkg/grpc/execution/v3alpha"
)
func Test_createTargetToCommand(t *testing.T) {
type args struct {
req *execution.CreateTargetRequest
}
tests := []struct {
name string
args args
want *command.AddTarget
}{
{
name: "nil",
args: args{nil},
want: &command.AddTarget{
Name: "",
TargetType: domain.TargetTypeUnspecified,
URL: "",
Timeout: 0,
Async: false,
InterruptOnError: false,
},
},
{
name: "all fields (async webhook)",
args: args{&execution.CreateTargetRequest{
Name: "target 1",
TargetType: &execution.CreateTargetRequest_RestWebhook{
RestWebhook: &execution.SetRESTWebhook{
Url: "https://example.com/hooks/1",
},
},
Timeout: durationpb.New(10 * time.Second),
ExecutionType: &execution.CreateTargetRequest_IsAsync{
IsAsync: true,
},
}},
want: &command.AddTarget{
Name: "target 1",
TargetType: domain.TargetTypeWebhook,
URL: "https://example.com/hooks/1",
Timeout: 10 * time.Second,
Async: true,
InterruptOnError: false,
},
},
{
name: "all fields (interrupting response)",
args: args{&execution.CreateTargetRequest{
Name: "target 1",
TargetType: &execution.CreateTargetRequest_RestRequestResponse{
RestRequestResponse: &execution.SetRESTRequestResponse{
Url: "https://example.com/hooks/1",
},
},
Timeout: durationpb.New(10 * time.Second),
ExecutionType: &execution.CreateTargetRequest_InterruptOnError{
InterruptOnError: true,
},
}},
want: &command.AddTarget{
Name: "target 1",
TargetType: domain.TargetTypeRequestResponse,
URL: "https://example.com/hooks/1",
Timeout: 10 * time.Second,
Async: false,
InterruptOnError: true,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := createTargetToCommand(tt.args.req)
assert.Equal(t, tt.want, got)
})
}
}
func Test_updateTargetToCommand(t *testing.T) {
type args struct {
req *execution.UpdateTargetRequest
}
tests := []struct {
name string
args args
want *command.ChangeTarget
}{
{
name: "nil",
args: args{nil},
want: nil,
},
{
name: "all fields nil",
args: args{&execution.UpdateTargetRequest{
Name: nil,
TargetType: nil,
Timeout: nil,
ExecutionType: nil,
}},
want: &command.ChangeTarget{
Name: nil,
TargetType: nil,
URL: nil,
Timeout: nil,
Async: nil,
InterruptOnError: nil,
},
},
{
name: "all fields empty",
args: args{&execution.UpdateTargetRequest{
Name: gu.Ptr(""),
TargetType: nil,
Timeout: durationpb.New(0),
ExecutionType: nil,
}},
want: &command.ChangeTarget{
Name: gu.Ptr(""),
TargetType: nil,
URL: nil,
Timeout: gu.Ptr(0 * time.Second),
Async: nil,
InterruptOnError: nil,
},
},
{
name: "all fields (async webhook)",
args: args{&execution.UpdateTargetRequest{
Name: gu.Ptr("target 1"),
TargetType: &execution.UpdateTargetRequest_RestWebhook{
RestWebhook: &execution.SetRESTWebhook{
Url: "https://example.com/hooks/1",
},
},
Timeout: durationpb.New(10 * time.Second),
ExecutionType: &execution.UpdateTargetRequest_IsAsync{
IsAsync: true,
},
}},
want: &command.ChangeTarget{
Name: gu.Ptr("target 1"),
TargetType: gu.Ptr(domain.TargetTypeWebhook),
URL: gu.Ptr("https://example.com/hooks/1"),
Timeout: gu.Ptr(10 * time.Second),
Async: gu.Ptr(true),
InterruptOnError: gu.Ptr(false),
},
},
{
name: "all fields (interrupting response)",
args: args{&execution.UpdateTargetRequest{
Name: gu.Ptr("target 1"),
TargetType: &execution.UpdateTargetRequest_RestRequestResponse{
RestRequestResponse: &execution.SetRESTRequestResponse{
Url: "https://example.com/hooks/1",
},
},
Timeout: durationpb.New(10 * time.Second),
ExecutionType: &execution.UpdateTargetRequest_InterruptOnError{
InterruptOnError: true,
},
}},
want: &command.ChangeTarget{
Name: gu.Ptr("target 1"),
TargetType: gu.Ptr(domain.TargetTypeRequestResponse),
URL: gu.Ptr("https://example.com/hooks/1"),
Timeout: gu.Ptr(10 * time.Second),
Async: gu.Ptr(false),
InterruptOnError: gu.Ptr(true),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := updateTargetToCommand(tt.args.req)
assert.Equal(t, tt.want, got)
})
}
}

View File

@ -12,18 +12,17 @@ import (
)
func (s *Server) SetEmail(ctx context.Context, req *user.SetEmailRequest) (resp *user.SetEmailResponse, err error) {
var resourceOwner string // TODO: check if still needed
var email *domain.Email
switch v := req.GetVerification().(type) {
case *user.SetEmailRequest_SendCode:
email, err = s.command.ChangeUserEmailURLTemplate(ctx, req.GetUserId(), resourceOwner, req.GetEmail(), s.userCodeAlg, v.SendCode.GetUrlTemplate())
email, err = s.command.ChangeUserEmailURLTemplate(ctx, req.GetUserId(), req.GetEmail(), s.userCodeAlg, v.SendCode.GetUrlTemplate())
case *user.SetEmailRequest_ReturnCode:
email, err = s.command.ChangeUserEmailReturnCode(ctx, req.GetUserId(), resourceOwner, req.GetEmail(), s.userCodeAlg)
email, err = s.command.ChangeUserEmailReturnCode(ctx, req.GetUserId(), req.GetEmail(), s.userCodeAlg)
case *user.SetEmailRequest_IsVerified:
email, err = s.command.ChangeUserEmailVerified(ctx, req.GetUserId(), resourceOwner, req.GetEmail())
email, err = s.command.ChangeUserEmailVerified(ctx, req.GetUserId(), req.GetEmail())
case nil:
email, err = s.command.ChangeUserEmail(ctx, req.GetUserId(), resourceOwner, req.GetEmail(), s.userCodeAlg)
email, err = s.command.ChangeUserEmail(ctx, req.GetUserId(), req.GetEmail(), s.userCodeAlg)
default:
err = zerrors.ThrowUnimplementedf(nil, "USERv2-Ahng0", "verification oneOf %T in method SetEmail not implemented", v)
}
@ -41,10 +40,36 @@ func (s *Server) SetEmail(ctx context.Context, req *user.SetEmailRequest) (resp
}, nil
}
func (s *Server) ResendEmailCode(ctx context.Context, req *user.ResendEmailCodeRequest) (resp *user.ResendEmailCodeResponse, err error) {
var email *domain.Email
switch v := req.GetVerification().(type) {
case *user.ResendEmailCodeRequest_SendCode:
email, err = s.command.ResendUserEmailCodeURLTemplate(ctx, req.GetUserId(), s.userCodeAlg, v.SendCode.GetUrlTemplate())
case *user.ResendEmailCodeRequest_ReturnCode:
email, err = s.command.ResendUserEmailReturnCode(ctx, req.GetUserId(), s.userCodeAlg)
case nil:
email, err = s.command.ResendUserEmailCode(ctx, req.GetUserId(), s.userCodeAlg)
default:
err = zerrors.ThrowUnimplementedf(nil, "USERv2-faj0l0nj5x", "verification oneOf %T in method ResendEmailCode not implemented", v)
}
if err != nil {
return nil, err
}
return &user.ResendEmailCodeResponse{
Details: &object.Details{
Sequence: email.Sequence,
ChangeDate: timestamppb.New(email.ChangeDate),
ResourceOwner: email.ResourceOwner,
},
VerificationCode: email.PlainCode,
}, nil
}
func (s *Server) VerifyEmail(ctx context.Context, req *user.VerifyEmailRequest) (*user.VerifyEmailResponse, error) {
details, err := s.command.VerifyUserEmail(ctx,
req.GetUserId(),
"", // TODO: check if still needed
req.GetVerificationCode(),
s.userCodeAlg,
)

View File

@ -3,7 +3,9 @@
package user_test
import (
"fmt"
"testing"
"time"
"github.com/muhlemmer/gu"
"github.com/stretchr/testify/assert"
@ -24,6 +26,14 @@ func TestServer_SetEmail(t *testing.T) {
want *user.SetEmailResponse
wantErr bool
}{
{
name: "user not existing",
req: &user.SetEmailRequest{
UserId: "xxx",
Email: "default-verifier@mouse.com",
},
wantErr: true,
},
{
name: "default verfication",
req: &user.SetEmailRequest{
@ -133,6 +143,107 @@ func TestServer_SetEmail(t *testing.T) {
}
}
func TestServer_ResendEmailCode(t *testing.T) {
userID := Tester.CreateHumanUser(CTX).GetUserId()
verifiedUserID := Tester.CreateHumanUserVerified(CTX, Tester.Organisation.ID, fmt.Sprintf("%d@mouse.com", time.Now().UnixNano())).GetUserId()
tests := []struct {
name string
req *user.ResendEmailCodeRequest
want *user.ResendEmailCodeResponse
wantErr bool
}{
{
name: "user not existing",
req: &user.ResendEmailCodeRequest{
UserId: "xxx",
},
wantErr: true,
},
{
name: "user no code",
req: &user.ResendEmailCodeRequest{
UserId: verifiedUserID,
},
wantErr: true,
},
{
name: "resend",
req: &user.ResendEmailCodeRequest{
UserId: userID,
},
want: &user.ResendEmailCodeResponse{
Details: &object.Details{
Sequence: 1,
ChangeDate: timestamppb.Now(),
ResourceOwner: Tester.Organisation.ID,
},
},
},
{
name: "custom url template",
req: &user.ResendEmailCodeRequest{
UserId: userID,
Verification: &user.ResendEmailCodeRequest_SendCode{
SendCode: &user.SendEmailVerificationCode{
UrlTemplate: gu.Ptr("https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}"),
},
},
},
want: &user.ResendEmailCodeResponse{
Details: &object.Details{
Sequence: 1,
ChangeDate: timestamppb.Now(),
ResourceOwner: Tester.Organisation.ID,
},
},
},
{
name: "template error",
req: &user.ResendEmailCodeRequest{
UserId: userID,
Verification: &user.ResendEmailCodeRequest_SendCode{
SendCode: &user.SendEmailVerificationCode{
UrlTemplate: gu.Ptr("{{"),
},
},
},
wantErr: true,
},
{
name: "return code",
req: &user.ResendEmailCodeRequest{
UserId: userID,
Verification: &user.ResendEmailCodeRequest_ReturnCode{
ReturnCode: &user.ReturnEmailVerificationCode{},
},
},
want: &user.ResendEmailCodeResponse{
Details: &object.Details{
Sequence: 1,
ChangeDate: timestamppb.Now(),
ResourceOwner: Tester.Organisation.ID,
},
VerificationCode: gu.Ptr("xxx"),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := Client.ResendEmailCode(CTX, tt.req)
if tt.wantErr {
require.Error(t, err)
} else {
require.NoError(t, err)
}
integration.AssertDetails(t, tt.want, got)
if tt.want.GetVerificationCode() != "" {
assert.NotEmpty(t, got.GetVerificationCode())
}
})
}
}
func TestServer_VerifyEmail(t *testing.T) {
userResp := Tester.CreateHumanUser(CTX)
tests := []struct {

View File

@ -12,18 +12,17 @@ import (
)
func (s *Server) SetPhone(ctx context.Context, req *user.SetPhoneRequest) (resp *user.SetPhoneResponse, err error) {
var resourceOwner string // TODO: check if still needed
var phone *domain.Phone
switch v := req.GetVerification().(type) {
case *user.SetPhoneRequest_SendCode:
phone, err = s.command.ChangeUserPhone(ctx, req.GetUserId(), resourceOwner, req.GetPhone(), s.userCodeAlg)
phone, err = s.command.ChangeUserPhone(ctx, req.GetUserId(), req.GetPhone(), s.userCodeAlg)
case *user.SetPhoneRequest_ReturnCode:
phone, err = s.command.ChangeUserPhoneReturnCode(ctx, req.GetUserId(), resourceOwner, req.GetPhone(), s.userCodeAlg)
phone, err = s.command.ChangeUserPhoneReturnCode(ctx, req.GetUserId(), req.GetPhone(), s.userCodeAlg)
case *user.SetPhoneRequest_IsVerified:
phone, err = s.command.ChangeUserPhoneVerified(ctx, req.GetUserId(), resourceOwner, req.GetPhone())
phone, err = s.command.ChangeUserPhoneVerified(ctx, req.GetUserId(), req.GetPhone())
case nil:
phone, err = s.command.ChangeUserPhone(ctx, req.GetUserId(), resourceOwner, req.GetPhone(), s.userCodeAlg)
phone, err = s.command.ChangeUserPhone(ctx, req.GetUserId(), req.GetPhone(), s.userCodeAlg)
default:
err = zerrors.ThrowUnimplementedf(nil, "USERv2-Ahng0", "verification oneOf %T in method SetPhone not implemented", v)
}
@ -41,10 +40,35 @@ func (s *Server) SetPhone(ctx context.Context, req *user.SetPhoneRequest) (resp
}, nil
}
func (s *Server) ResendPhoneCode(ctx context.Context, req *user.ResendPhoneCodeRequest) (resp *user.ResendPhoneCodeResponse, err error) {
var phone *domain.Phone
switch v := req.GetVerification().(type) {
case *user.ResendPhoneCodeRequest_SendCode:
phone, err = s.command.ResendUserPhoneCode(ctx, req.GetUserId(), s.userCodeAlg)
case *user.ResendPhoneCodeRequest_ReturnCode:
phone, err = s.command.ResendUserPhoneCodeReturnCode(ctx, req.GetUserId(), s.userCodeAlg)
case nil:
phone, err = s.command.ResendUserPhoneCode(ctx, req.GetUserId(), s.userCodeAlg)
default:
err = zerrors.ThrowUnimplementedf(nil, "USERv2-ResendUserPhoneCode", "verification oneOf %T in method SetPhone not implemented", v)
}
if err != nil {
return nil, err
}
return &user.ResendPhoneCodeResponse{
Details: &object.Details{
Sequence: phone.Sequence,
ChangeDate: timestamppb.New(phone.ChangeDate),
ResourceOwner: phone.ResourceOwner,
},
VerificationCode: phone.PlainCode,
}, nil
}
func (s *Server) VerifyPhone(ctx context.Context, req *user.VerifyPhoneRequest) (*user.VerifyPhoneResponse, error) {
details, err := s.command.VerifyUserPhone(ctx,
req.GetUserId(),
"", // TODO: check if still needed
req.GetVerificationCode(),
s.userCodeAlg,
)

View File

@ -3,7 +3,9 @@
package user_test
import (
"fmt"
"testing"
"time"
"github.com/muhlemmer/gu"
"github.com/stretchr/testify/assert"
@ -118,6 +120,80 @@ func TestServer_SetPhone(t *testing.T) {
}
}
func TestServer_ResendPhoneCode(t *testing.T) {
userID := Tester.CreateHumanUser(CTX).GetUserId()
verifiedUserID := Tester.CreateHumanUserVerified(CTX, Tester.Organisation.ID, fmt.Sprintf("%d@mouse.com", time.Now().UnixNano())).GetUserId()
tests := []struct {
name string
req *user.ResendPhoneCodeRequest
want *user.ResendPhoneCodeResponse
wantErr bool
}{
{
name: "user not existing",
req: &user.ResendPhoneCodeRequest{
UserId: "xxx",
},
wantErr: true,
},
{
name: "user not existing",
req: &user.ResendPhoneCodeRequest{
UserId: verifiedUserID,
},
wantErr: true,
},
{
name: "resend code",
req: &user.ResendPhoneCodeRequest{
UserId: userID,
Verification: &user.ResendPhoneCodeRequest_SendCode{
SendCode: &user.SendPhoneVerificationCode{},
},
},
want: &user.ResendPhoneCodeResponse{
Details: &object.Details{
Sequence: 1,
ChangeDate: timestamppb.Now(),
ResourceOwner: Tester.Organisation.ID,
},
},
},
{
name: "return code",
req: &user.ResendPhoneCodeRequest{
UserId: userID,
Verification: &user.ResendPhoneCodeRequest_ReturnCode{
ReturnCode: &user.ReturnPhoneVerificationCode{},
},
},
want: &user.ResendPhoneCodeResponse{
Details: &object.Details{
Sequence: 1,
ChangeDate: timestamppb.Now(),
ResourceOwner: Tester.Organisation.ID,
},
VerificationCode: gu.Ptr("xxx"),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := Client.ResendPhoneCode(CTX, tt.req)
if tt.wantErr {
require.Error(t, err)
} else {
require.NoError(t, err)
}
integration.AssertDetails(t, tt.want, got)
if tt.want.GetVerificationCode() != "" {
assert.NotEmpty(t, got.GetVerificationCode())
}
})
}
}
func TestServer_VerifyPhone(t *testing.T) {
userResp := Tester.CreateHumanUser(CTX)
tests := []struct {

View File

@ -147,6 +147,10 @@ func (c *CookieHandler) httpSetWithSameSite(w http.ResponseWriter, name, host, v
secure := c.secureOnly || (sameSite == http.SameSiteNoneMode && domain == "localhost")
// prefix the cookie for secure cookies (TLS only, therefore not for samesite none on http://localhost)
prefixedName := SetCookiePrefix(name, c.secureOnly, c.prefix)
// in case the host prefix is set, we need to make sure the domain is not set (otherwise the browser will reject the cookie)
if secure && c.prefix == PrefixHost {
domain = ""
}
http.SetCookie(w, &http.Cookie{
Name: prefixedName,
Value: value,

View File

@ -46,7 +46,7 @@ func NewUserAgentHandler(config *UserAgentCookieConfig, cookieKey []byte, idGene
opts := []http_utils.CookieHandlerOpt{
http_utils.WithEncryption(cookieKey, cookieKey),
http_utils.WithMaxAge(int(config.MaxAge.Seconds())),
http_utils.WithPrefix(http_utils.PrefixSecure),
http_utils.WithPrefix(http_utils.PrefixHost),
}
if !externalSecure {
opts = append(opts, http_utils.WithUnsecure())

View File

@ -99,7 +99,7 @@ func (l *Login) handleRegisterSMSCheck(w http.ResponseWriter, r *http.Request) {
if formData.Code == "" {
data.Phone = formData.NewPhone
if formData.NewPhone != formData.Phone {
_, err = l.command.ChangeUserPhone(ctx, authReq.UserID, authReq.UserOrgID, formData.NewPhone, l.userCodeAlg)
_, err = l.command.ChangeUserPhone(ctx, authReq.UserID, formData.NewPhone, l.userCodeAlg)
if err != nil {
// stay in edit more
data.Edit = true
@ -109,7 +109,7 @@ func (l *Login) handleRegisterSMSCheck(w http.ResponseWriter, r *http.Request) {
return
}
_, err = l.command.VerifyUserPhone(ctx, authReq.UserID, authReq.UserOrgID, formData.Code, l.userCodeAlg)
_, err = l.command.VerifyUserPhone(ctx, authReq.UserID, formData.Code, l.userCodeAlg)
if err != nil {
l.renderRegisterSMS(w, r, authReq, data, err)
return

View File

@ -0,0 +1,177 @@
package command
import (
"context"
"net/url"
"time"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/eventstore/v1/models"
"github.com/zitadel/zitadel/internal/repository/target"
"github.com/zitadel/zitadel/internal/zerrors"
)
type AddTarget struct {
models.ObjectRoot
Name string
TargetType domain.TargetType
URL string
Timeout time.Duration
Async bool
InterruptOnError bool
}
func (a *AddTarget) IsValid() error {
if a.Name == "" {
return zerrors.ThrowInvalidArgument(nil, "COMMAND-ddqbm9us5p", "Errors.Target.Invalid")
}
if a.Timeout == 0 {
return zerrors.ThrowInvalidArgument(nil, "COMMAND-39f35d8uri", "Errors.Target.NoTimeout")
}
_, err := url.Parse(a.URL)
if err != nil || a.URL == "" {
return zerrors.ThrowInvalidArgument(nil, "COMMAND-1r2k6qo6wg", "Errors.Target.InvalidURL")
}
return nil
}
func (c *Commands) AddTarget(ctx context.Context, add *AddTarget, resourceOwner string) (_ *domain.ObjectDetails, err error) {
if resourceOwner == "" {
return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-brml926e2d", "Errors.IDMissing")
}
if err := add.IsValid(); err != nil {
return nil, err
}
if add.AggregateID == "" {
add.AggregateID, err = c.idGenerator.Next()
if err != nil {
return nil, err
}
}
wm := NewTargetWriteModel(add.AggregateID, resourceOwner)
pushedEvents, err := c.eventstore.Push(ctx, target.NewAddedEvent(
ctx,
TargetAggregateFromWriteModel(&wm.WriteModel),
add.Name,
add.TargetType,
add.URL,
add.Timeout,
add.Async,
add.InterruptOnError,
))
if err != nil {
return nil, err
}
if err := AppendAndReduce(wm, pushedEvents...); err != nil {
return nil, err
}
return writeModelToObjectDetails(&wm.WriteModel), nil
}
type ChangeTarget struct {
models.ObjectRoot
Name *string
TargetType *domain.TargetType
URL *string
Timeout *time.Duration
Async *bool
InterruptOnError *bool
}
func (a *ChangeTarget) IsValid() error {
if a.AggregateID == "" {
return zerrors.ThrowInvalidArgument(nil, "COMMAND-1l6ympeagp", "Errors.IDMissing")
}
if a.Name != nil && *a.Name == "" {
return zerrors.ThrowInvalidArgument(nil, "COMMAND-d1wx4lm0zr", "Errors.Target.Invalid")
}
if a.Timeout != nil && *a.Timeout == 0 {
return zerrors.ThrowInvalidArgument(nil, "COMMAND-08b39vdi57", "Errors.Target.NoTimeout")
}
if a.URL != nil {
_, err := url.Parse(*a.URL)
if err != nil || *a.URL == "" {
return zerrors.ThrowInvalidArgument(nil, "COMMAND-jsbaera7b6", "Errors.Target.InvalidURL")
}
}
return nil
}
func (c *Commands) ChangeTarget(ctx context.Context, change *ChangeTarget, resourceOwner string) (*domain.ObjectDetails, error) {
if resourceOwner == "" {
return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-zqibgg0wwh", "Errors.IDMissing")
}
if err := change.IsValid(); err != nil {
return nil, err
}
existing, err := c.getTargetWriteModelByID(ctx, change.AggregateID, resourceOwner)
if err != nil {
return nil, err
}
if !existing.State.Exists() {
return nil, zerrors.ThrowNotFound(nil, "COMMAND-xj14f2cccn", "Errors.Target.NotFound")
}
changedEvent := existing.NewChangedEvent(
ctx,
TargetAggregateFromWriteModel(&existing.WriteModel),
change.Name,
change.TargetType,
change.URL,
change.Timeout,
change.Async,
change.InterruptOnError)
if changedEvent == nil {
return writeModelToObjectDetails(&existing.WriteModel), nil
}
pushedEvents, err := c.eventstore.Push(ctx, changedEvent)
if err != nil {
return nil, err
}
err = AppendAndReduce(existing, pushedEvents...)
if err != nil {
return nil, err
}
return writeModelToObjectDetails(&existing.WriteModel), nil
}
func (c *Commands) DeleteTarget(ctx context.Context, id, resourceOwner string) (*domain.ObjectDetails, error) {
if id == "" || resourceOwner == "" {
return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-obqos2l3no", "Errors.IDMissing")
}
existing, err := c.getTargetWriteModelByID(ctx, id, resourceOwner)
if err != nil {
return nil, err
}
if !existing.State.Exists() {
return nil, zerrors.ThrowNotFound(nil, "COMMAND-k4s7ucu0ax", "Errors.Target.NotFound")
}
if err := c.pushAppendAndReduce(ctx,
existing,
target.NewRemovedEvent(ctx,
TargetAggregateFromWriteModel(&existing.WriteModel),
existing.Name,
),
); err != nil {
return nil, err
}
return writeModelToObjectDetails(&existing.WriteModel), nil
}
func (c *Commands) getTargetWriteModelByID(ctx context.Context, id string, resourceOwner string) (*TargetWriteModel, error) {
wm := NewTargetWriteModel(id, resourceOwner)
err := c.eventstore.FilterToQueryReducer(ctx, wm)
if err != nil {
return nil, err
}
return wm, nil
}

View File

@ -0,0 +1,127 @@
package command
import (
"context"
"time"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/repository/action"
"github.com/zitadel/zitadel/internal/repository/target"
)
type TargetWriteModel struct {
eventstore.WriteModel
Name string
TargetType domain.TargetType
URL string
Timeout time.Duration
Async bool
InterruptOnError bool
State domain.TargetState
}
func NewTargetWriteModel(id string, resourceOwner string) *TargetWriteModel {
return &TargetWriteModel{
WriteModel: eventstore.WriteModel{
AggregateID: id,
ResourceOwner: resourceOwner,
InstanceID: resourceOwner,
},
}
}
func (wm *TargetWriteModel) Reduce() error {
for _, event := range wm.Events {
switch e := event.(type) {
case *target.AddedEvent:
wm.Name = e.Name
wm.TargetType = e.TargetType
wm.URL = e.URL
wm.Timeout = e.Timeout
wm.Async = e.Async
wm.State = domain.TargetActive
case *target.ChangedEvent:
if e.Name != nil {
wm.Name = *e.Name
}
if e.TargetType != nil {
wm.TargetType = *e.TargetType
}
if e.URL != nil {
wm.URL = *e.URL
}
if e.Timeout != nil {
wm.Timeout = *e.Timeout
}
if e.Async != nil {
wm.Async = *e.Async
}
if e.InterruptOnError != nil {
wm.InterruptOnError = *e.InterruptOnError
}
case *action.RemovedEvent:
wm.State = domain.TargetRemoved
}
}
return wm.WriteModel.Reduce()
}
func (wm *TargetWriteModel) Query() *eventstore.SearchQueryBuilder {
return eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent).
ResourceOwner(wm.ResourceOwner).
AddQuery().
AggregateTypes(target.AggregateType).
AggregateIDs(wm.AggregateID).
EventTypes(target.AddedEventType,
target.ChangedEventType,
target.RemovedEventType).
Builder()
}
func (wm *TargetWriteModel) NewChangedEvent(
ctx context.Context,
agg *eventstore.Aggregate,
name *string,
targetType *domain.TargetType,
url *string,
timeout *time.Duration,
async *bool,
interruptOnError *bool,
) *target.ChangedEvent {
changes := make([]target.Changes, 0)
if name != nil && wm.Name != *name {
changes = append(changes, target.ChangeName(wm.Name, *name))
}
if targetType != nil && wm.TargetType != *targetType {
changes = append(changes, target.ChangeTargetType(*targetType))
}
if url != nil && wm.URL != *url {
changes = append(changes, target.ChangeURL(*url))
}
if timeout != nil && wm.Timeout != *timeout {
changes = append(changes, target.ChangeTimeout(*timeout))
}
if async != nil && wm.Async != *async {
changes = append(changes, target.ChangeAsync(*async))
}
if interruptOnError != nil && wm.InterruptOnError != *interruptOnError {
changes = append(changes, target.ChangeInterruptOnError(*interruptOnError))
}
if len(changes) == 0 {
return nil
}
return target.NewChangedEvent(ctx, agg, changes)
}
func TargetAggregateFromWriteModel(wm *eventstore.WriteModel) *eventstore.Aggregate {
return &eventstore.Aggregate{
ID: wm.AggregateID,
Type: target.AggregateType,
ResourceOwner: wm.ResourceOwner,
InstanceID: wm.InstanceID,
Version: target.AggregateVersion,
}
}

View File

@ -0,0 +1,676 @@
package command
import (
"context"
"testing"
"time"
"github.com/muhlemmer/gu"
"github.com/stretchr/testify/assert"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/eventstore/v1/models"
"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"
)
func TestCommands_AddTarget(t *testing.T) {
type fields struct {
eventstore *eventstore.Eventstore
idGenerator id.Generator
}
type args struct {
ctx context.Context
add *AddTarget
resourceOwner string
}
type res struct {
id string
details *domain.ObjectDetails
err func(error) bool
}
tests := []struct {
name string
fields fields
args args
res res
}{
{
"no resourceowner, error",
fields{
eventstore: eventstoreExpect(t),
},
args{
ctx: context.Background(),
add: &AddTarget{},
resourceOwner: "",
},
res{
err: zerrors.IsErrorInvalidArgument,
},
},
{
"no name, error",
fields{
eventstore: eventstoreExpect(t),
},
args{
ctx: context.Background(),
add: &AddTarget{},
resourceOwner: "org1",
},
res{
err: zerrors.IsErrorInvalidArgument,
},
},
{
"no timeout, error",
fields{
eventstore: eventstoreExpect(t),
},
args{
ctx: context.Background(),
add: &AddTarget{
Name: "name",
},
resourceOwner: "org1",
},
res{
err: zerrors.IsErrorInvalidArgument,
},
},
{
"no url, error",
fields{
eventstore: eventstoreExpect(t),
},
args{
ctx: context.Background(),
add: &AddTarget{
Name: "name",
Timeout: time.Second,
URL: "",
},
resourceOwner: "org1",
},
res{
err: zerrors.IsErrorInvalidArgument,
},
},
{
"no parsable url, error",
fields{
eventstore: eventstoreExpect(t),
},
args{
ctx: context.Background(),
add: &AddTarget{
Name: "name",
Timeout: time.Second,
URL: "://",
},
resourceOwner: "org1",
},
res{
err: zerrors.IsErrorInvalidArgument,
},
},
{
"unique constraint failed, error",
fields{
eventstore: eventstoreExpect(t,
expectPushFailed(
zerrors.ThrowPreconditionFailed(nil, "id", "name already exists"),
target.NewAddedEvent(context.Background(),
target.NewAggregate("id1", "org1"),
"name",
domain.TargetTypeWebhook,
"https://example.com",
time.Second,
false,
false,
),
),
),
idGenerator: mock.ExpectID(t, "id1"),
},
args{
ctx: context.Background(),
add: &AddTarget{
Name: "name",
URL: "https://example.com",
Timeout: time.Second,
TargetType: domain.TargetTypeWebhook,
},
resourceOwner: "org1",
},
res{
err: zerrors.IsPreconditionFailed,
},
},
{
"push ok",
fields{
eventstore: eventstoreExpect(t,
expectPush(
target.NewAddedEvent(context.Background(),
target.NewAggregate("id1", "org1"),
"name",
domain.TargetTypeWebhook,
"https://example.com",
time.Second,
false,
false,
),
),
),
idGenerator: mock.ExpectID(t, "id1"),
},
args{
ctx: context.Background(),
add: &AddTarget{
Name: "name",
TargetType: domain.TargetTypeWebhook,
Timeout: time.Second,
URL: "https://example.com",
},
resourceOwner: "org1",
},
res{
id: "id1",
details: &domain.ObjectDetails{
ResourceOwner: "org1",
},
},
},
{
"push full ok",
fields{
eventstore: eventstoreExpect(t,
expectPush(
target.NewAddedEvent(context.Background(),
target.NewAggregate("id1", "org1"),
"name",
domain.TargetTypeWebhook,
"https://example.com",
time.Second,
true,
true,
),
),
),
idGenerator: mock.ExpectID(t, "id1"),
},
args{
ctx: context.Background(),
add: &AddTarget{
Name: "name",
TargetType: domain.TargetTypeWebhook,
URL: "https://example.com",
Timeout: time.Second,
Async: true,
InterruptOnError: true,
},
resourceOwner: "org1",
},
res{
id: "id1",
details: &domain.ObjectDetails{
ResourceOwner: "org1",
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := &Commands{
eventstore: tt.fields.eventstore,
idGenerator: tt.fields.idGenerator,
}
details, err := c.AddTarget(tt.args.ctx, tt.args.add, tt.args.resourceOwner)
if tt.res.err == nil {
assert.NoError(t, err)
}
if tt.res.err != nil && !tt.res.err(err) {
t.Errorf("got wrong err: %v ", err)
}
if tt.res.err == nil {
assert.Equal(t, tt.res.id, tt.args.add.AggregateID)
assert.Equal(t, tt.res.details, details)
}
})
}
}
func TestCommands_ChangeTarget(t *testing.T) {
type fields struct {
eventstore *eventstore.Eventstore
}
type args struct {
ctx context.Context
change *ChangeTarget
resourceOwner string
}
type res struct {
details *domain.ObjectDetails
err func(error) bool
}
tests := []struct {
name string
fields fields
args args
res res
}{
{
"resourceowner missing, error",
fields{
eventstore: eventstoreExpect(t),
},
args{
ctx: context.Background(),
change: &ChangeTarget{},
resourceOwner: "",
},
res{
err: zerrors.IsErrorInvalidArgument,
},
},
{
"id missing, error",
fields{
eventstore: eventstoreExpect(t),
},
args{
ctx: context.Background(),
change: &ChangeTarget{},
resourceOwner: "org1",
},
res{
err: zerrors.IsErrorInvalidArgument,
},
},
{
"name empty, error",
fields{
eventstore: eventstoreExpect(t),
},
args{
ctx: context.Background(),
change: &ChangeTarget{
Name: gu.Ptr(""),
},
resourceOwner: "org1",
},
res{
err: zerrors.IsErrorInvalidArgument,
},
},
{
"timeout empty, error",
fields{
eventstore: eventstoreExpect(t),
},
args{
ctx: context.Background(),
change: &ChangeTarget{
Timeout: gu.Ptr(time.Duration(0)),
},
resourceOwner: "org1",
},
res{
err: zerrors.IsErrorInvalidArgument,
},
},
{
"url empty, error",
fields{
eventstore: eventstoreExpect(t),
},
args{
ctx: context.Background(),
change: &ChangeTarget{
URL: gu.Ptr(""),
},
resourceOwner: "org1",
},
res{
err: zerrors.IsErrorInvalidArgument,
},
},
{
"url not parsable, error",
fields{
eventstore: eventstoreExpect(t),
},
args{
ctx: context.Background(),
change: &ChangeTarget{
URL: gu.Ptr("://"),
},
resourceOwner: "org1",
},
res{
err: zerrors.IsErrorInvalidArgument,
},
},
{
"not found, error",
fields{
eventstore: eventstoreExpect(t,
expectFilter(),
),
},
args{
ctx: context.Background(),
change: &ChangeTarget{
ObjectRoot: models.ObjectRoot{
AggregateID: "id1",
},
Name: gu.Ptr("name"),
},
resourceOwner: "org1",
},
res{
err: zerrors.IsNotFound,
},
},
{
"no changes",
fields{
eventstore: eventstoreExpect(t,
expectFilter(
eventFromEventPusher(
target.NewAddedEvent(context.Background(),
target.NewAggregate("id1", "org1"),
"name",
domain.TargetTypeWebhook,
"https://example.com",
0,
false,
false,
),
),
),
),
},
args{
ctx: context.Background(),
change: &ChangeTarget{
ObjectRoot: models.ObjectRoot{
AggregateID: "id1",
},
TargetType: gu.Ptr(domain.TargetTypeWebhook),
},
resourceOwner: "org1",
},
res{
details: &domain.ObjectDetails{
ResourceOwner: "org1",
},
},
},
{
"unique constraint failed, error",
fields{
eventstore: eventstoreExpect(t,
expectFilter(
eventFromEventPusher(
target.NewAddedEvent(context.Background(),
target.NewAggregate("id1", "org1"),
"name",
domain.TargetTypeWebhook,
"https://example.com",
0,
false,
false,
),
),
),
expectPushFailed(
zerrors.ThrowPreconditionFailed(nil, "id", "name already exists"),
target.NewChangedEvent(context.Background(),
target.NewAggregate("id1", "org1"),
[]target.Changes{
target.ChangeName("name", "name2"),
},
),
),
),
},
args{
ctx: context.Background(),
change: &ChangeTarget{
ObjectRoot: models.ObjectRoot{
AggregateID: "id1",
},
Name: gu.Ptr("name2"),
},
resourceOwner: "org1",
},
res{
err: zerrors.IsPreconditionFailed,
},
},
{
"push ok",
fields{
eventstore: eventstoreExpect(t,
expectFilter(
eventFromEventPusher(
target.NewAddedEvent(context.Background(),
target.NewAggregate("id1", "org1"),
"name",
domain.TargetTypeWebhook,
"https://example.com",
0,
false,
false,
),
),
),
expectPush(
target.NewChangedEvent(context.Background(),
target.NewAggregate("id1", "org1"),
[]target.Changes{
target.ChangeName("name", "name2"),
},
),
),
),
},
args{
ctx: context.Background(),
change: &ChangeTarget{
ObjectRoot: models.ObjectRoot{
AggregateID: "id1",
},
Name: gu.Ptr("name2"),
},
resourceOwner: "org1",
},
res{
details: &domain.ObjectDetails{
ResourceOwner: "org1",
},
},
},
{
"push full ok",
fields{
eventstore: eventstoreExpect(t,
expectFilter(
eventFromEventPusher(
target.NewAddedEvent(context.Background(),
target.NewAggregate("id1", "org1"),
"name",
domain.TargetTypeWebhook,
"https://example.com",
0,
false,
false,
),
),
),
expectPush(
target.NewChangedEvent(context.Background(),
target.NewAggregate("id1", "org1"),
[]target.Changes{
target.ChangeName("name", "name2"),
target.ChangeURL("https://example2.com"),
target.ChangeTargetType(domain.TargetTypeRequestResponse),
target.ChangeTimeout(time.Second),
target.ChangeAsync(true),
target.ChangeInterruptOnError(true),
},
),
),
),
},
args{
ctx: context.Background(),
change: &ChangeTarget{
ObjectRoot: models.ObjectRoot{
AggregateID: "id1",
},
Name: gu.Ptr("name2"),
URL: gu.Ptr("https://example2.com"),
TargetType: gu.Ptr(domain.TargetTypeRequestResponse),
Timeout: gu.Ptr(time.Second),
Async: gu.Ptr(true),
InterruptOnError: gu.Ptr(true),
},
resourceOwner: "org1",
},
res{
details: &domain.ObjectDetails{
ResourceOwner: "org1",
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := &Commands{
eventstore: tt.fields.eventstore,
}
details, err := c.ChangeTarget(tt.args.ctx, tt.args.change, tt.args.resourceOwner)
if tt.res.err == nil {
assert.NoError(t, err)
}
if tt.res.err != nil && !tt.res.err(err) {
t.Errorf("got wrong err: %v ", err)
}
if tt.res.err == nil {
assert.Equal(t, tt.res.details, details)
}
})
}
}
func TestCommands_DeleteTarget(t *testing.T) {
type fields struct {
eventstore *eventstore.Eventstore
}
type args struct {
ctx context.Context
id string
resourceOwner string
}
type res struct {
details *domain.ObjectDetails
err func(error) bool
}
tests := []struct {
name string
fields fields
args args
res res
}{
{
"id missing, error",
fields{
eventstore: eventstoreExpect(t),
},
args{
ctx: context.Background(),
id: "",
resourceOwner: "org1",
},
res{
err: zerrors.IsErrorInvalidArgument,
},
},
{
"not found, error",
fields{
eventstore: eventstoreExpect(t,
expectFilter(),
),
},
args{
ctx: context.Background(),
id: "id1",
resourceOwner: "org1",
},
res{
err: zerrors.IsNotFound,
},
},
{
"remove ok",
fields{
eventstore: eventstoreExpect(t,
expectFilter(
eventFromEventPusher(
target.NewAddedEvent(context.Background(),
target.NewAggregate("id1", "org1"),
"name",
domain.TargetTypeWebhook,
"https://example.com",
0,
false,
false,
),
),
),
expectPush(
target.NewRemovedEvent(context.Background(),
target.NewAggregate("id1", "org1"),
"name",
),
),
),
},
args{
ctx: context.Background(),
id: "id1",
resourceOwner: "org1",
},
res{
details: &domain.ObjectDetails{
ResourceOwner: "org1",
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := &Commands{
eventstore: tt.fields.eventstore,
}
details, err := c.DeleteTarget(tt.args.ctx, tt.args.id, tt.args.resourceOwner)
if tt.res.err == nil {
assert.NoError(t, err)
}
if tt.res.err != nil && !tt.res.err(err) {
t.Errorf("got wrong err: %v ", err)
}
if tt.res.err == nil {
assert.Equal(t, tt.res.details, details)
}
})
}
}

View File

@ -56,7 +56,7 @@ func (c *Commands) AddOIDCAppCommand(app *addOIDCApp, clientSecretAlg crypto.Has
}
for _, origin := range app.AdditionalOrigins {
if !http_util.IsOrigin(origin) {
if !http_util.IsOrigin(strings.TrimSpace(origin)) {
return nil, zerrors.ThrowInvalidArgument(nil, "V2-DqWPX", "Errors.Invalid.Argument")
}
}
@ -98,19 +98,19 @@ func (c *Commands) AddOIDCAppCommand(app *addOIDCApp, clientSecretAlg crypto.Has
app.ID,
app.ClientID,
app.ClientSecret,
app.RedirectUris,
trimStringSliceWhiteSpaces(app.RedirectUris),
app.ResponseTypes,
app.GrantTypes,
app.ApplicationType,
app.AuthMethodType,
app.PostLogoutRedirectUris,
trimStringSliceWhiteSpaces(app.PostLogoutRedirectUris),
app.DevMode,
app.AccessTokenType,
app.AccessTokenRoleAssertion,
app.IDTokenRoleAssertion,
app.IDTokenUserinfoAssertion,
app.ClockSkew,
app.AdditionalOrigins,
trimStringSliceWhiteSpaces(app.AdditionalOrigins),
app.SkipSuccessPageForNativeApp,
),
}, nil
@ -182,19 +182,19 @@ func (c *Commands) addOIDCApplicationWithID(ctx context.Context, oidcApp *domain
oidcApp.AppID,
oidcApp.ClientID,
oidcApp.ClientSecret,
oidcApp.RedirectUris,
trimStringSliceWhiteSpaces(oidcApp.RedirectUris),
oidcApp.ResponseTypes,
oidcApp.GrantTypes,
oidcApp.ApplicationType,
oidcApp.AuthMethodType,
oidcApp.PostLogoutRedirectUris,
trimStringSliceWhiteSpaces(oidcApp.PostLogoutRedirectUris),
oidcApp.DevMode,
oidcApp.AccessTokenType,
oidcApp.AccessTokenRoleAssertion,
oidcApp.IDTokenRoleAssertion,
oidcApp.IDTokenUserinfoAssertion,
oidcApp.ClockSkew,
oidcApp.AdditionalOrigins,
trimStringSliceWhiteSpaces(oidcApp.AdditionalOrigins),
oidcApp.SkipNativeAppSuccessPage,
))
@ -233,8 +233,8 @@ func (c *Commands) ChangeOIDCApplication(ctx context.Context, oidc *domain.OIDCA
ctx,
projectAgg,
oidc.AppID,
oidc.RedirectUris,
oidc.PostLogoutRedirectUris,
trimStringSliceWhiteSpaces(oidc.RedirectUris),
trimStringSliceWhiteSpaces(oidc.PostLogoutRedirectUris),
oidc.ResponseTypes,
oidc.GrantTypes,
oidc.ApplicationType,
@ -246,7 +246,7 @@ func (c *Commands) ChangeOIDCApplication(ctx context.Context, oidc *domain.OIDCA
oidc.IDTokenRoleAssertion,
oidc.IDTokenUserinfoAssertion,
oidc.ClockSkew,
oidc.AdditionalOrigins,
trimStringSliceWhiteSpaces(oidc.AdditionalOrigins),
oidc.SkipNativeAppSuccessPage,
)
if err != nil {
@ -359,3 +359,10 @@ func getOIDCAppWriteModel(ctx context.Context, filter preparation.FilterToQueryR
err = appWriteModel.Reduce()
return appWriteModel, err
}
func trimStringSliceWhiteSpaces(slice []string) []string {
for i, s := range slice {
slice[i] = strings.TrimSpace(s)
}
return slice
}

View File

@ -82,7 +82,7 @@ func TestAddOIDCApp(t *testing.T) {
},
},
{
name: "project not exists",
name: "project doesn't exist",
fields: fields{},
args: args{
app: &addOIDCApp{
@ -108,6 +108,73 @@ func TestAddOIDCApp(t *testing.T) {
CreateErr: zerrors.ThrowNotFound(nil, "PROJE-6swVG", ""),
},
},
{
name: "correct, using uris with whitespaces",
fields: fields{
idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "clientID"),
},
args: args{
app: &addOIDCApp{
AddApp: AddApp{
Aggregate: *agg,
ID: "id",
Name: "name",
},
RedirectUris: []string{" https://test.ch "},
PostLogoutRedirectUris: []string{" https://test.ch/logout "},
GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode},
ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode},
Version: domain.OIDCVersionV1,
AdditionalOrigins: []string{" https://sub.test.ch "},
ApplicationType: domain.OIDCApplicationTypeWeb,
AuthMethodType: domain.OIDCAuthMethodTypeNone,
AccessTokenType: domain.OIDCTokenTypeBearer,
},
filter: NewMultiFilter().
Append(func(ctx context.Context, queryFactory *eventstore.SearchQueryBuilder) ([]eventstore.Event, error) {
return []eventstore.Event{
project.NewProjectAddedEvent(
ctx,
&agg.Aggregate,
"project",
false,
false,
false,
domain.PrivateLabelingSettingUnspecified,
),
}, nil
}).
Filter(),
},
want: Want{
Commands: []eventstore.Command{
project.NewApplicationAddedEvent(ctx, &agg.Aggregate,
"id",
"name",
),
project.NewOIDCConfigAddedEvent(ctx, &agg.Aggregate,
domain.OIDCVersionV1,
"id",
"clientID@project",
nil,
[]string{"https://test.ch"},
[]domain.OIDCResponseType{domain.OIDCResponseTypeCode},
[]domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode},
domain.OIDCApplicationTypeWeb,
domain.OIDCAuthMethodTypeNone,
[]string{"https://test.ch/logout"},
false,
domain.OIDCTokenTypeBearer,
false,
false,
false,
0,
[]string{"https://sub.test.ch"},
false,
),
},
},
},
{
name: "correct",
fields: fields{
@ -279,6 +346,111 @@ func TestCommandSide_AddOIDCApplication(t *testing.T) {
err: zerrors.IsErrorInvalidArgument,
},
},
{
name: "create oidc app basic using whitespaces in uris, ok",
fields: fields{
eventstore: eventstoreExpect(
t,
expectFilter(
eventFromEventPusher(
project.NewProjectAddedEvent(context.Background(),
&project.NewAggregate("project1", "org1").Aggregate,
"project", true, true, true,
domain.PrivateLabelingSettingUnspecified),
),
),
expectPush(
project.NewApplicationAddedEvent(context.Background(),
&project.NewAggregate("project1", "org1").Aggregate,
"app1",
"app",
),
project.NewOIDCConfigAddedEvent(context.Background(),
&project.NewAggregate("project1", "org1").Aggregate,
domain.OIDCVersionV1,
"app1",
"client1@project",
&crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte("a"),
},
[]string{"https://test.ch"},
[]domain.OIDCResponseType{domain.OIDCResponseTypeCode},
[]domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode},
domain.OIDCApplicationTypeWeb,
domain.OIDCAuthMethodTypePost,
[]string{"https://test.ch/logout"},
true,
domain.OIDCTokenTypeBearer,
true,
true,
true,
time.Second*1,
[]string{"https://sub.test.ch"},
true,
),
),
),
idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "app1", "client1"),
},
args: args{
ctx: context.Background(),
oidcApp: &domain.OIDCApp{
ObjectRoot: models.ObjectRoot{
AggregateID: "project1",
},
AppName: "app",
AuthMethodType: domain.OIDCAuthMethodTypePost,
OIDCVersion: domain.OIDCVersionV1,
RedirectUris: []string{" https://test.ch "},
ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode},
GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode},
ApplicationType: domain.OIDCApplicationTypeWeb,
PostLogoutRedirectUris: []string{" https://test.ch/logout "},
DevMode: true,
AccessTokenType: domain.OIDCTokenTypeBearer,
AccessTokenRoleAssertion: true,
IDTokenRoleAssertion: true,
IDTokenUserinfoAssertion: true,
ClockSkew: time.Second * 1,
AdditionalOrigins: []string{" https://sub.test.ch "},
SkipNativeAppSuccessPage: true,
},
resourceOwner: "org1",
secretGenerator: GetMockSecretGenerator(t),
},
res: res{
want: &domain.OIDCApp{
ObjectRoot: models.ObjectRoot{
AggregateID: "project1",
ResourceOwner: "org1",
},
AppID: "app1",
AppName: "app",
ClientID: "client1@project",
ClientSecretString: "a",
AuthMethodType: domain.OIDCAuthMethodTypePost,
OIDCVersion: domain.OIDCVersionV1,
RedirectUris: []string{"https://test.ch"},
ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode},
GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode},
ApplicationType: domain.OIDCApplicationTypeWeb,
PostLogoutRedirectUris: []string{"https://test.ch/logout"},
DevMode: true,
AccessTokenType: domain.OIDCTokenTypeBearer,
AccessTokenRoleAssertion: true,
IDTokenRoleAssertion: true,
IDTokenUserinfoAssertion: true,
ClockSkew: time.Second * 1,
AdditionalOrigins: []string{"https://sub.test.ch"},
SkipNativeAppSuccessPage: true,
State: domain.AppStateActive,
Compliance: &domain.Compliance{},
},
},
},
{
name: "create oidc app basic, ok",
fields: fields{
@ -592,6 +764,80 @@ func TestCommandSide_ChangeOIDCApplication(t *testing.T) {
err: zerrors.IsPreconditionFailed,
},
},
{
name: "no changes whitespaces are ignored, precondition error",
fields: fields{
eventstore: eventstoreExpect(
t,
expectFilter(
eventFromEventPusher(
project.NewApplicationAddedEvent(context.Background(),
&project.NewAggregate("project1", "org1").Aggregate,
"app1",
"app",
),
),
eventFromEventPusher(
project.NewOIDCConfigAddedEvent(context.Background(),
&project.NewAggregate("project1", "org1").Aggregate,
domain.OIDCVersionV1,
"app1",
"client1@project",
&crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte("a"),
},
[]string{"https://test.ch"},
[]domain.OIDCResponseType{domain.OIDCResponseTypeCode},
[]domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode},
domain.OIDCApplicationTypeWeb,
domain.OIDCAuthMethodTypePost,
[]string{"https://test.ch/logout"},
true,
domain.OIDCTokenTypeBearer,
true,
true,
true,
time.Second*1,
[]string{"https://sub.test.ch"},
true,
),
),
),
),
},
args: args{
ctx: context.Background(),
oidcApp: &domain.OIDCApp{
ObjectRoot: models.ObjectRoot{
AggregateID: "project1",
},
AppID: "app1",
AppName: "app",
AuthMethodType: domain.OIDCAuthMethodTypePost,
OIDCVersion: domain.OIDCVersionV1,
RedirectUris: []string{"https://test.ch "},
ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode},
GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode},
ApplicationType: domain.OIDCApplicationTypeWeb,
PostLogoutRedirectUris: []string{" https://test.ch/logout"},
DevMode: true,
AccessTokenType: domain.OIDCTokenTypeBearer,
AccessTokenRoleAssertion: true,
IDTokenRoleAssertion: true,
IDTokenUserinfoAssertion: true,
ClockSkew: time.Second * 1,
AdditionalOrigins: []string{" https://sub.test.ch "},
SkipNativeAppSuccessPage: true,
},
resourceOwner: "org1",
},
res: res{
err: zerrors.IsPreconditionFailed,
},
},
{
name: "change oidc app, ok",
fields: fields{
@ -652,11 +898,11 @@ func TestCommandSide_ChangeOIDCApplication(t *testing.T) {
AppName: "app",
AuthMethodType: domain.OIDCAuthMethodTypePost,
OIDCVersion: domain.OIDCVersionV1,
RedirectUris: []string{"https://test-change.ch"},
RedirectUris: []string{" https://test-change.ch "},
ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode},
GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode},
ApplicationType: domain.OIDCApplicationTypeWeb,
PostLogoutRedirectUris: []string{"https://test-change.ch/logout"},
PostLogoutRedirectUris: []string{" https://test-change.ch/logout "},
DevMode: true,
AccessTokenType: domain.OIDCTokenTypeJWT,
AccessTokenRoleAssertion: false,

View File

@ -16,30 +16,52 @@ import (
// ChangeUserEmail sets a user's email address, generates a code
// and triggers a notification e-mail with the default confirmation URL format.
func (c *Commands) ChangeUserEmail(ctx context.Context, userID, resourceOwner, email string, alg crypto.EncryptionAlgorithm) (*domain.Email, error) {
return c.changeUserEmailWithCode(ctx, userID, resourceOwner, email, alg, false, "")
func (c *Commands) ChangeUserEmail(ctx context.Context, userID, email string, alg crypto.EncryptionAlgorithm) (*domain.Email, error) {
return c.changeUserEmailWithCode(ctx, userID, email, alg, false, "")
}
// ChangeUserEmailURLTemplate sets a user's email address, generates a code
// and triggers a notification e-mail with the confirmation URL rendered from the passed urlTmpl.
// urlTmpl must be a valid [tmpl.Template].
func (c *Commands) ChangeUserEmailURLTemplate(ctx context.Context, userID, resourceOwner, email string, alg crypto.EncryptionAlgorithm, urlTmpl string) (*domain.Email, error) {
func (c *Commands) ChangeUserEmailURLTemplate(ctx context.Context, userID, email string, alg crypto.EncryptionAlgorithm, urlTmpl string) (*domain.Email, error) {
if err := domain.RenderConfirmURLTemplate(io.Discard, urlTmpl, userID, "code", "orgID"); err != nil {
return nil, err
}
return c.changeUserEmailWithCode(ctx, userID, resourceOwner, email, alg, false, urlTmpl)
return c.changeUserEmailWithCode(ctx, userID, email, alg, false, urlTmpl)
}
// ChangeUserEmailReturnCode sets a user's email address, generates a code and does not send a notification email.
// The generated plain text code will be set in the returned Email object.
func (c *Commands) ChangeUserEmailReturnCode(ctx context.Context, userID, resourceOwner, email string, alg crypto.EncryptionAlgorithm) (*domain.Email, error) {
return c.changeUserEmailWithCode(ctx, userID, resourceOwner, email, alg, true, "")
func (c *Commands) ChangeUserEmailReturnCode(ctx context.Context, userID, email string, alg crypto.EncryptionAlgorithm) (*domain.Email, error) {
return c.changeUserEmailWithCode(ctx, userID, email, alg, true, "")
}
// ResendUserEmailCode generates a new code if there is a code existing
// and triggers a notification e-mail with the default confirmation URL format.
func (c *Commands) ResendUserEmailCode(ctx context.Context, userID string, alg crypto.EncryptionAlgorithm) (*domain.Email, error) {
return c.resendUserEmailCode(ctx, userID, alg, false, "")
}
// ResendUserEmailCodeURLTemplate generates a new code if there is a code existing
// and triggers a notification e-mail with the confirmation URL rendered from the passed urlTmpl.
// urlTmpl must be a valid [tmpl.Template].
func (c *Commands) ResendUserEmailCodeURLTemplate(ctx context.Context, userID string, alg crypto.EncryptionAlgorithm, urlTmpl string) (*domain.Email, error) {
if err := domain.RenderConfirmURLTemplate(io.Discard, urlTmpl, userID, "code", "orgID"); err != nil {
return nil, err
}
return c.resendUserEmailCode(ctx, userID, alg, false, urlTmpl)
}
// ResendUserEmailReturnCode generates a new code if there is a code existing and does not send a notification email.
// The generated plain text code will be set in the returned Email object.
func (c *Commands) ResendUserEmailReturnCode(ctx context.Context, userID string, alg crypto.EncryptionAlgorithm) (*domain.Email, error) {
return c.resendUserEmailCode(ctx, userID, alg, true, "")
}
// ChangeUserEmailVerified sets a user's email address and marks it is verified.
// No code is generated and no confirmation e-mail is send.
func (c *Commands) ChangeUserEmailVerified(ctx context.Context, userID, resourceOwner, email string) (*domain.Email, error) {
cmd, err := c.NewUserEmailEvents(ctx, userID, resourceOwner)
func (c *Commands) ChangeUserEmailVerified(ctx context.Context, userID, email string) (*domain.Email, error) {
cmd, err := c.NewUserEmailEvents(ctx, userID)
if err != nil {
return nil, err
}
@ -53,29 +75,46 @@ func (c *Commands) ChangeUserEmailVerified(ctx context.Context, userID, resource
return cmd.Push(ctx)
}
func (c *Commands) changeUserEmailWithCode(ctx context.Context, userID, resourceOwner, email string, alg crypto.EncryptionAlgorithm, returnCode bool, urlTmpl string) (*domain.Email, error) {
func (c *Commands) changeUserEmailWithCode(ctx context.Context, userID, email string, alg crypto.EncryptionAlgorithm, returnCode bool, urlTmpl string) (*domain.Email, error) {
config, err := secretGeneratorConfig(ctx, c.eventstore.Filter, domain.SecretGeneratorTypeVerifyEmailCode)
if err != nil {
return nil, err
}
gen := crypto.NewEncryptionGenerator(*config, alg)
return c.changeUserEmailWithGenerator(ctx, userID, resourceOwner, email, gen, returnCode, urlTmpl)
return c.changeUserEmailWithGenerator(ctx, userID, email, gen, returnCode, urlTmpl)
}
func (c *Commands) resendUserEmailCode(ctx context.Context, userID string, alg crypto.EncryptionAlgorithm, returnCode bool, urlTmpl string) (*domain.Email, error) {
config, err := secretGeneratorConfig(ctx, c.eventstore.Filter, domain.SecretGeneratorTypeVerifyEmailCode) //nolint:staticcheck
if err != nil {
return nil, err
}
gen := crypto.NewEncryptionGenerator(*config, alg)
return c.resendUserEmailCodeWithGenerator(ctx, userID, gen, returnCode, urlTmpl)
}
// changeUserEmailWithGenerator set a user's email address.
// returnCode controls if the plain text version of the code will be set in the return object.
// When the plain text code is returned, no notification e-mail will be send to the user.
// urlTmpl allows changing the target URL that is used by the e-mail and should be a validated Go template, if used.
func (c *Commands) changeUserEmailWithGenerator(ctx context.Context, userID, resourceOwner, email string, gen crypto.Generator, returnCode bool, urlTmpl string) (*domain.Email, error) {
cmd, err := c.changeUserEmailWithGeneratorEvents(ctx, userID, resourceOwner, email, gen, returnCode, urlTmpl)
func (c *Commands) changeUserEmailWithGenerator(ctx context.Context, userID, email string, gen crypto.Generator, returnCode bool, urlTmpl string) (*domain.Email, error) {
cmd, err := c.changeUserEmailWithGeneratorEvents(ctx, userID, email, gen, returnCode, urlTmpl)
if err != nil {
return nil, err
}
return cmd.Push(ctx)
}
func (c *Commands) changeUserEmailWithGeneratorEvents(ctx context.Context, userID, resourceOwner, email string, gen crypto.Generator, returnCode bool, urlTmpl string) (*UserEmailEvents, error) {
cmd, err := c.NewUserEmailEvents(ctx, userID, resourceOwner)
func (c *Commands) resendUserEmailCodeWithGenerator(ctx context.Context, userID string, gen crypto.Generator, returnCode bool, urlTmpl string) (*domain.Email, error) {
cmd, err := c.resendUserEmailCodeWithGeneratorEvents(ctx, userID, gen, returnCode, urlTmpl)
if err != nil {
return nil, err
}
return cmd.Push(ctx)
}
func (c *Commands) changeUserEmailWithGeneratorEvents(ctx context.Context, userID, email string, gen crypto.Generator, returnCode bool, urlTmpl string) (*UserEmailEvents, error) {
cmd, err := c.NewUserEmailEvents(ctx, userID)
if err != nil {
return nil, err
}
@ -93,17 +132,36 @@ func (c *Commands) changeUserEmailWithGeneratorEvents(ctx context.Context, userI
return cmd, nil
}
func (c *Commands) VerifyUserEmail(ctx context.Context, userID, resourceOwner, code string, alg crypto.EncryptionAlgorithm) (*domain.ObjectDetails, error) {
func (c *Commands) resendUserEmailCodeWithGeneratorEvents(ctx context.Context, userID string, gen crypto.Generator, returnCode bool, urlTmpl string) (*UserEmailEvents, error) {
cmd, err := c.NewUserEmailEvents(ctx, userID)
if err != nil {
return nil, err
}
if authz.GetCtxData(ctx).UserID != userID {
if err = c.checkPermission(ctx, domain.PermissionUserWrite, cmd.aggregate.ResourceOwner, userID); err != nil {
return nil, err
}
}
if cmd.model.Code == nil {
return nil, zerrors.ThrowPreconditionFailed(err, "EMAIL-5w5ilin4yt", "Errors.User.Code.Empty")
}
if err = cmd.AddGeneratedCode(ctx, gen, urlTmpl, returnCode); err != nil {
return nil, err
}
return cmd, nil
}
func (c *Commands) VerifyUserEmail(ctx context.Context, userID, code string, alg crypto.EncryptionAlgorithm) (*domain.ObjectDetails, error) {
config, err := secretGeneratorConfig(ctx, c.eventstore.Filter, domain.SecretGeneratorTypeVerifyEmailCode)
if err != nil {
return nil, err
}
gen := crypto.NewEncryptionGenerator(*config, alg)
return c.verifyUserEmailWithGenerator(ctx, userID, resourceOwner, code, gen)
return c.verifyUserEmailWithGenerator(ctx, userID, code, gen)
}
func (c *Commands) verifyUserEmailWithGenerator(ctx context.Context, userID, resourceOwner, code string, gen crypto.Generator) (*domain.ObjectDetails, error) {
cmd, err := c.NewUserEmailEvents(ctx, userID, resourceOwner)
func (c *Commands) verifyUserEmailWithGenerator(ctx context.Context, userID, code string, gen crypto.Generator) (*domain.ObjectDetails, error) {
cmd, err := c.NewUserEmailEvents(ctx, userID)
if err != nil {
return nil, err
}
@ -131,12 +189,12 @@ type UserEmailEvents struct {
// NewUserEmailEvents constructs a UserEmailEvents with a Human Email Write Model,
// filtered by userID and resourceOwner.
// If a model cannot be found, or it's state is invalid and error is returned.
func (c *Commands) NewUserEmailEvents(ctx context.Context, userID, resourceOwner string) (*UserEmailEvents, error) {
func (c *Commands) NewUserEmailEvents(ctx context.Context, userID string) (*UserEmailEvents, error) {
if userID == "" {
return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-0Gzs3", "Errors.User.Email.IDMissing")
}
model, err := c.emailWriteModel(ctx, userID, resourceOwner)
model, err := c.emailWriteModel(ctx, userID, "")
if err != nil {
return nil, err
}

File diff suppressed because it is too large Load Diff

View File

@ -15,20 +15,20 @@ import (
// ChangeUserPhone sets a user's phone number, generates a code
// and triggers a notification sms.
func (c *Commands) ChangeUserPhone(ctx context.Context, userID, resourceOwner, phone string, alg crypto.EncryptionAlgorithm) (*domain.Phone, error) {
return c.changeUserPhoneWithCode(ctx, userID, resourceOwner, phone, alg, false)
func (c *Commands) ChangeUserPhone(ctx context.Context, userID, phone string, alg crypto.EncryptionAlgorithm) (*domain.Phone, error) {
return c.changeUserPhoneWithCode(ctx, userID, phone, alg, false)
}
// ChangeUserPhoneReturnCode sets a user's phone number, generates a code and does not send a notification sms.
// The generated plain text code will be set in the returned Phone object.
func (c *Commands) ChangeUserPhoneReturnCode(ctx context.Context, userID, resourceOwner, phone string, alg crypto.EncryptionAlgorithm) (*domain.Phone, error) {
return c.changeUserPhoneWithCode(ctx, userID, resourceOwner, phone, alg, true)
func (c *Commands) ChangeUserPhoneReturnCode(ctx context.Context, userID, phone string, alg crypto.EncryptionAlgorithm) (*domain.Phone, error) {
return c.changeUserPhoneWithCode(ctx, userID, phone, alg, true)
}
// ChangeUserPhoneVerified sets a user's phone number and marks it is verified.
// No code is generated and no confirmation sms is send.
func (c *Commands) ChangeUserPhoneVerified(ctx context.Context, userID, resourceOwner, phone string) (*domain.Phone, error) {
cmd, err := c.NewUserPhoneEvents(ctx, userID, resourceOwner)
func (c *Commands) ChangeUserPhoneVerified(ctx context.Context, userID, phone string) (*domain.Phone, error) {
cmd, err := c.NewUserPhoneEvents(ctx, userID)
if err != nil {
return nil, err
}
@ -42,20 +42,41 @@ func (c *Commands) ChangeUserPhoneVerified(ctx context.Context, userID, resource
return cmd.Push(ctx)
}
func (c *Commands) changeUserPhoneWithCode(ctx context.Context, userID, resourceOwner, phone string, alg crypto.EncryptionAlgorithm, returnCode bool) (*domain.Phone, error) {
// ResendUserPhoneCode generates a code
// and triggers a notification sms.
func (c *Commands) ResendUserPhoneCode(ctx context.Context, userID string, alg crypto.EncryptionAlgorithm) (*domain.Phone, error) {
return c.resendUserPhoneCode(ctx, userID, alg, false)
}
// ResendUserPhoneCodeReturnCode generates a code and does not send a notification sms.
// The generated plain text code will be set in the returned Phone object.
func (c *Commands) ResendUserPhoneCodeReturnCode(ctx context.Context, userID string, alg crypto.EncryptionAlgorithm) (*domain.Phone, error) {
return c.resendUserPhoneCode(ctx, userID, alg, true)
}
func (c *Commands) changeUserPhoneWithCode(ctx context.Context, userID, phone string, alg crypto.EncryptionAlgorithm, returnCode bool) (*domain.Phone, error) {
config, err := secretGeneratorConfig(ctx, c.eventstore.Filter, domain.SecretGeneratorTypeVerifyPhoneCode)
if err != nil {
return nil, err
}
gen := crypto.NewEncryptionGenerator(*config, alg)
return c.changeUserPhoneWithGenerator(ctx, userID, resourceOwner, phone, gen, returnCode)
return c.changeUserPhoneWithGenerator(ctx, userID, phone, gen, returnCode)
}
func (c *Commands) resendUserPhoneCode(ctx context.Context, userID string, alg crypto.EncryptionAlgorithm, returnCode bool) (*domain.Phone, error) {
config, err := secretGeneratorConfig(ctx, c.eventstore.Filter, domain.SecretGeneratorTypeVerifyPhoneCode) //nolint:staticcheck
if err != nil {
return nil, err
}
gen := crypto.NewEncryptionGenerator(*config, alg)
return c.resendUserPhoneCodeWithGenerator(ctx, userID, gen, returnCode)
}
// changeUserPhoneWithGenerator set a user's phone number.
// returnCode controls if the plain text version of the code will be set in the return object.
// When the plain text code is returned, no notification sms will be send to the user.
func (c *Commands) changeUserPhoneWithGenerator(ctx context.Context, userID, resourceOwner, phone string, gen crypto.Generator, returnCode bool) (*domain.Phone, error) {
cmd, err := c.NewUserPhoneEvents(ctx, userID, resourceOwner)
func (c *Commands) changeUserPhoneWithGenerator(ctx context.Context, userID, phone string, gen crypto.Generator, returnCode bool) (*domain.Phone, error) {
cmd, err := c.NewUserPhoneEvents(ctx, userID)
if err != nil {
return nil, err
}
@ -73,17 +94,39 @@ func (c *Commands) changeUserPhoneWithGenerator(ctx context.Context, userID, res
return cmd.Push(ctx)
}
func (c *Commands) VerifyUserPhone(ctx context.Context, userID, resourceOwner, code string, alg crypto.EncryptionAlgorithm) (*domain.ObjectDetails, error) {
// resendUserPhoneCodeWithGenerator generates a new code.
// returnCode controls if the plain text version of the code will be set in the return object.
// When the plain text code is returned, no notification sms will be send to the user.
func (c *Commands) resendUserPhoneCodeWithGenerator(ctx context.Context, userID string, gen crypto.Generator, returnCode bool) (*domain.Phone, error) {
cmd, err := c.NewUserPhoneEvents(ctx, userID)
if err != nil {
return nil, err
}
if authz.GetCtxData(ctx).UserID != userID {
if err = c.checkPermission(ctx, domain.PermissionUserWrite, cmd.aggregate.ResourceOwner, userID); err != nil {
return nil, err
}
}
if cmd.model.Code == nil {
return nil, zerrors.ThrowPreconditionFailed(err, "PHONE-5xrra88eq8", "Errors.User.Code.Empty")
}
if err = cmd.AddGeneratedCode(ctx, gen, returnCode); err != nil {
return nil, err
}
return cmd.Push(ctx)
}
func (c *Commands) VerifyUserPhone(ctx context.Context, userID, code string, alg crypto.EncryptionAlgorithm) (*domain.ObjectDetails, error) {
config, err := secretGeneratorConfig(ctx, c.eventstore.Filter, domain.SecretGeneratorTypeVerifyPhoneCode)
if err != nil {
return nil, err
}
gen := crypto.NewEncryptionGenerator(*config, alg)
return c.verifyUserPhoneWithGenerator(ctx, userID, resourceOwner, code, gen)
return c.verifyUserPhoneWithGenerator(ctx, userID, code, gen)
}
func (c *Commands) verifyUserPhoneWithGenerator(ctx context.Context, userID, resourceOwner, code string, gen crypto.Generator) (*domain.ObjectDetails, error) {
cmd, err := c.NewUserPhoneEvents(ctx, userID, resourceOwner)
func (c *Commands) verifyUserPhoneWithGenerator(ctx context.Context, userID, code string, gen crypto.Generator) (*domain.ObjectDetails, error) {
cmd, err := c.NewUserPhoneEvents(ctx, userID)
if err != nil {
return nil, err
}
@ -111,12 +154,12 @@ type UserPhoneEvents struct {
// NewUserPhoneEvents constructs a UserPhoneEvents with a Human Phone Write Model,
// filtered by userID and resourceOwner.
// If a model cannot be found, or it's state is invalid and error is returned.
func (c *Commands) NewUserPhoneEvents(ctx context.Context, userID, resourceOwner string) (*UserPhoneEvents, error) {
func (c *Commands) NewUserPhoneEvents(ctx context.Context, userID string) (*UserPhoneEvents, error) {
if userID == "" {
return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-xP292j", "Errors.User.Phone.IDMissing")
}
model, err := c.phoneWriteModelByID(ctx, userID, resourceOwner)
model, err := c.phoneWriteModelByID(ctx, userID, "")
if err != nil {
return nil, err
}

View File

@ -26,9 +26,8 @@ func TestCommands_ChangeUserPhone(t *testing.T) {
checkPermission domain.PermissionCheck
}
type args struct {
userID string
resourceOwner string
phone string
userID string
phone string
}
tests := []struct {
name string
@ -74,9 +73,8 @@ func TestCommands_ChangeUserPhone(t *testing.T) {
checkPermission: newMockPermissionCheckNotAllowed(),
},
args: args{
userID: "user1",
resourceOwner: "org1",
phone: "",
userID: "user1",
phone: "",
},
wantErr: zerrors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied"),
},
@ -118,9 +116,8 @@ func TestCommands_ChangeUserPhone(t *testing.T) {
checkPermission: newMockPermissionCheckAllowed(),
},
args: args{
userID: "user1",
resourceOwner: "org1",
phone: "",
userID: "user1",
phone: "",
},
wantErr: zerrors.ThrowInvalidArgument(nil, "PHONE-Zt0NV", "Errors.User.Phone.Empty"),
},
@ -162,9 +159,8 @@ func TestCommands_ChangeUserPhone(t *testing.T) {
checkPermission: newMockPermissionCheckAllowed(),
},
args: args{
userID: "user1",
resourceOwner: "org1",
phone: "+41791234567",
userID: "user1",
phone: "+41791234567",
},
wantErr: zerrors.ThrowPreconditionFailed(nil, "COMMAND-Uch5e", "Errors.User.Phone.NotChanged"),
},
@ -175,7 +171,7 @@ func TestCommands_ChangeUserPhone(t *testing.T) {
eventstore: tt.fields.eventstore,
checkPermission: tt.fields.checkPermission,
}
_, err := c.ChangeUserPhone(context.Background(), tt.args.userID, tt.args.resourceOwner, tt.args.phone, crypto.CreateMockEncryptionAlg(gomock.NewController(t)))
_, err := c.ChangeUserPhone(context.Background(), tt.args.userID, tt.args.phone, crypto.CreateMockEncryptionAlg(gomock.NewController(t)))
require.ErrorIs(t, err, tt.wantErr)
// successful cases are tested in TestCommands_changeUserPhoneWithGenerator
})
@ -188,9 +184,8 @@ func TestCommands_ChangeUserPhoneReturnCode(t *testing.T) {
checkPermission domain.PermissionCheck
}
type args struct {
userID string
resourceOwner string
phone string
userID string
phone string
}
tests := []struct {
name string
@ -236,9 +231,8 @@ func TestCommands_ChangeUserPhoneReturnCode(t *testing.T) {
checkPermission: newMockPermissionCheckNotAllowed(),
},
args: args{
userID: "user1",
resourceOwner: "org1",
phone: "+41791234567",
userID: "user1",
phone: "+41791234567",
},
wantErr: zerrors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied"),
},
@ -280,9 +274,8 @@ func TestCommands_ChangeUserPhoneReturnCode(t *testing.T) {
checkPermission: newMockPermissionCheckAllowed(),
},
args: args{
userID: "user1",
resourceOwner: "org1",
phone: "",
userID: "user1",
phone: "",
},
wantErr: zerrors.ThrowInvalidArgument(nil, "PHONE-Zt0NV", "Errors.User.Phone.Empty"),
},
@ -293,22 +286,245 @@ func TestCommands_ChangeUserPhoneReturnCode(t *testing.T) {
eventstore: tt.fields.eventstore,
checkPermission: tt.fields.checkPermission,
}
_, err := c.ChangeUserPhoneReturnCode(context.Background(), tt.args.userID, tt.args.resourceOwner, tt.args.phone, crypto.CreateMockEncryptionAlg(gomock.NewController(t)))
_, err := c.ChangeUserPhoneReturnCode(context.Background(), tt.args.userID, tt.args.phone, crypto.CreateMockEncryptionAlg(gomock.NewController(t)))
require.ErrorIs(t, err, tt.wantErr)
// successful cases are tested in TestCommands_changeUserPhoneWithGenerator
})
}
}
func TestCommands_ResendUserPhoneCode(t *testing.T) {
type fields struct {
eventstore *eventstore.Eventstore
checkPermission domain.PermissionCheck
}
type args struct {
userID string
}
tests := []struct {
name string
fields fields
args args
wantErr error
}{
{
name: "missing permission",
fields: fields{
eventstore: eventstoreExpect(
t,
expectFilter(
eventFromEventPusher(
instance.NewSecretGeneratorAddedEvent(context.Background(),
&instance.NewAggregate("inst1").Aggregate,
domain.SecretGeneratorTypeVerifyPhoneCode,
12, time.Minute, true, true, true, true,
),
),
),
expectFilter(
eventFromEventPusher(
func() eventstore.Command {
event := user.NewHumanAddedEvent(context.Background(),
&user.NewAggregate("user1", "org1").Aggregate,
"username",
"firstname",
"lastname",
"nickname",
"displayname",
language.German,
domain.GenderUnspecified,
"email@test.ch",
true,
)
event.AddPhoneData("+41791234567")
return event
}(),
),
),
),
checkPermission: newMockPermissionCheckNotAllowed(),
},
args: args{
userID: "user1",
},
wantErr: zerrors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied"),
},
{
name: "no code",
fields: fields{
eventstore: eventstoreExpect(
t,
expectFilter(
eventFromEventPusher(
instance.NewSecretGeneratorAddedEvent(context.Background(),
&instance.NewAggregate("inst1").Aggregate,
domain.SecretGeneratorTypeVerifyPhoneCode,
12, time.Minute, true, true, true, true,
),
),
),
expectFilter(
eventFromEventPusher(
func() eventstore.Command {
event := user.NewHumanAddedEvent(context.Background(),
&user.NewAggregate("user1", "org1").Aggregate,
"username",
"firstname",
"lastname",
"nickname",
"displayname",
language.German,
domain.GenderUnspecified,
"email@test.ch",
true,
)
event.AddPhoneData("+41791234567")
return event
}(),
),
),
),
checkPermission: newMockPermissionCheckAllowed(),
},
args: args{
userID: "user1",
},
wantErr: zerrors.ThrowPreconditionFailed(nil, "PHONE-5xrra88eq8", "Errors.User.Code.Empty"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := &Commands{
eventstore: tt.fields.eventstore,
checkPermission: tt.fields.checkPermission,
}
_, err := c.ResendUserPhoneCode(context.Background(), tt.args.userID, crypto.CreateMockEncryptionAlg(gomock.NewController(t)))
require.ErrorIs(t, err, tt.wantErr)
// successful cases are tested in TestCommands_resendUserPhoneCodeWithGenerator
})
}
}
func TestCommands_ResendUserPhoneCodeReturnCode(t *testing.T) {
type fields struct {
eventstore *eventstore.Eventstore
checkPermission domain.PermissionCheck
}
type args struct {
userID string
}
tests := []struct {
name string
fields fields
args args
wantErr error
}{
{
name: "missing permission",
fields: fields{
eventstore: eventstoreExpect(
t,
expectFilter(
eventFromEventPusher(
instance.NewSecretGeneratorAddedEvent(context.Background(),
&instance.NewAggregate("inst1").Aggregate,
domain.SecretGeneratorTypeVerifyPhoneCode,
12, time.Minute, true, true, true, true,
),
),
),
expectFilter(
eventFromEventPusher(
func() eventstore.Command {
event := user.NewHumanAddedEvent(context.Background(),
&user.NewAggregate("user1", "org1").Aggregate,
"username",
"firstname",
"lastname",
"nickname",
"displayname",
language.German,
domain.GenderUnspecified,
"email@test.ch",
true,
)
event.AddPhoneData("+41791234567")
return event
}(),
),
),
),
checkPermission: newMockPermissionCheckNotAllowed(),
},
args: args{
userID: "user1",
},
wantErr: zerrors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied"),
},
{
name: "no code",
fields: fields{
eventstore: eventstoreExpect(
t,
expectFilter(
eventFromEventPusher(
instance.NewSecretGeneratorAddedEvent(context.Background(),
&instance.NewAggregate("inst1").Aggregate,
domain.SecretGeneratorTypeVerifyEmailCode,
12, time.Minute, true, true, true, true,
),
),
),
expectFilter(
eventFromEventPusher(
func() eventstore.Command {
event := user.NewHumanAddedEvent(context.Background(),
&user.NewAggregate("user1", "org1").Aggregate,
"username",
"firstname",
"lastname",
"nickname",
"displayname",
language.German,
domain.GenderUnspecified,
"email@test.ch",
true,
)
event.AddPhoneData("+41791234567")
return event
}(),
),
),
),
checkPermission: newMockPermissionCheckAllowed(),
},
args: args{
userID: "user1",
},
wantErr: zerrors.ThrowPreconditionFailed(nil, "PHONE-5xrra88eq8", "Errors.User.Code.Empty"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := &Commands{
eventstore: tt.fields.eventstore,
checkPermission: tt.fields.checkPermission,
}
_, err := c.ResendUserPhoneCodeReturnCode(context.Background(), tt.args.userID, crypto.CreateMockEncryptionAlg(gomock.NewController(t)))
require.ErrorIs(t, err, tt.wantErr)
// successful cases are tested in TestCommands_resendUserPhoneCodeWithGenerator
})
}
}
func TestCommands_ChangeUserPhoneVerified(t *testing.T) {
type fields struct {
eventstore *eventstore.Eventstore
checkPermission domain.PermissionCheck
}
type args struct {
userID string
resourceOwner string
phone string
userID string
phone string
}
tests := []struct {
name string
@ -324,9 +540,8 @@ func TestCommands_ChangeUserPhoneVerified(t *testing.T) {
checkPermission: newMockPermissionCheckNotAllowed(),
},
args: args{
userID: "",
resourceOwner: "org1",
phone: "+41791234567",
userID: "",
phone: "+41791234567",
},
wantErr: zerrors.ThrowInvalidArgument(nil, "COMMAND-xP292j", "Errors.User.Phone.IDMissing"),
},
@ -359,9 +574,8 @@ func TestCommands_ChangeUserPhoneVerified(t *testing.T) {
checkPermission: newMockPermissionCheckNotAllowed(),
},
args: args{
userID: "user1",
resourceOwner: "org1",
phone: "+41791234567",
userID: "user1",
phone: "+41791234567",
},
wantErr: zerrors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied"),
},
@ -394,9 +608,8 @@ func TestCommands_ChangeUserPhoneVerified(t *testing.T) {
checkPermission: newMockPermissionCheckAllowed(),
},
args: args{
userID: "user1",
resourceOwner: "org1",
phone: "",
userID: "user1",
phone: "",
},
wantErr: zerrors.ThrowInvalidArgument(nil, "PHONE-Zt0NV", "Errors.User.Phone.Empty"),
},
@ -438,9 +651,8 @@ func TestCommands_ChangeUserPhoneVerified(t *testing.T) {
checkPermission: newMockPermissionCheckAllowed(),
},
args: args{
userID: "user1",
resourceOwner: "org1",
phone: "+41791234568",
userID: "user1",
phone: "+41791234568",
},
want: &domain.Phone{
ObjectRoot: models.ObjectRoot{
@ -458,7 +670,7 @@ func TestCommands_ChangeUserPhoneVerified(t *testing.T) {
eventstore: tt.fields.eventstore,
checkPermission: tt.fields.checkPermission,
}
got, err := c.ChangeUserPhoneVerified(context.Background(), tt.args.userID, tt.args.resourceOwner, tt.args.phone)
got, err := c.ChangeUserPhoneVerified(context.Background(), tt.args.userID, tt.args.phone)
require.ErrorIs(t, err, tt.wantErr)
assert.Equal(t, got, tt.want)
})
@ -471,10 +683,9 @@ func TestCommands_changeUserPhoneWithGenerator(t *testing.T) {
checkPermission domain.PermissionCheck
}
type args struct {
userID string
resourceOwner string
phone string
returnCode bool
userID string
phone string
returnCode bool
}
tests := []struct {
name string
@ -489,10 +700,9 @@ func TestCommands_changeUserPhoneWithGenerator(t *testing.T) {
eventstore: eventstoreExpect(t),
},
args: args{
userID: "",
resourceOwner: "org1",
phone: "+41791234567",
returnCode: false,
userID: "",
phone: "+41791234567",
returnCode: false,
},
wantErr: zerrors.ThrowInvalidArgument(nil, "COMMAND-xP292j", "Errors.User.Phone.IDMissing"),
},
@ -525,10 +735,9 @@ func TestCommands_changeUserPhoneWithGenerator(t *testing.T) {
checkPermission: newMockPermissionCheckNotAllowed(),
},
args: args{
userID: "user1",
resourceOwner: "org1",
phone: "+41791234567",
returnCode: false,
userID: "user1",
phone: "+41791234567",
returnCode: false,
},
wantErr: zerrors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied"),
},
@ -561,10 +770,9 @@ func TestCommands_changeUserPhoneWithGenerator(t *testing.T) {
checkPermission: newMockPermissionCheckAllowed(),
},
args: args{
userID: "user1",
resourceOwner: "org1",
phone: "",
returnCode: false,
userID: "user1",
phone: "",
returnCode: false,
},
wantErr: zerrors.ThrowInvalidArgument(nil, "PHONE-Zt0NV", "Errors.User.Phone.Empty"),
},
@ -597,10 +805,9 @@ func TestCommands_changeUserPhoneWithGenerator(t *testing.T) {
checkPermission: newMockPermissionCheckAllowed(),
},
args: args{
userID: "user1",
resourceOwner: "org1",
phone: "+41791234567",
returnCode: false,
userID: "user1",
phone: "+41791234567",
returnCode: false,
},
wantErr: zerrors.ThrowPreconditionFailed(nil, "COMMAND-Uch5e", "Errors.User.Phone.NotChanged"),
},
@ -650,10 +857,9 @@ func TestCommands_changeUserPhoneWithGenerator(t *testing.T) {
checkPermission: newMockPermissionCheckAllowed(),
},
args: args{
userID: "user1",
resourceOwner: "org1",
phone: "+41791234568",
returnCode: false,
userID: "user1",
phone: "+41791234568",
returnCode: false,
},
want: &domain.Phone{
ObjectRoot: models.ObjectRoot{
@ -710,10 +916,9 @@ func TestCommands_changeUserPhoneWithGenerator(t *testing.T) {
checkPermission: newMockPermissionCheckAllowed(),
},
args: args{
userID: "user1",
resourceOwner: "org1",
phone: "+41791234568",
returnCode: true,
userID: "user1",
phone: "+41791234568",
returnCode: true,
},
want: &domain.Phone{
ObjectRoot: models.ObjectRoot{
@ -732,7 +937,251 @@ func TestCommands_changeUserPhoneWithGenerator(t *testing.T) {
eventstore: tt.fields.eventstore,
checkPermission: tt.fields.checkPermission,
}
got, err := c.changeUserPhoneWithGenerator(context.Background(), tt.args.userID, tt.args.resourceOwner, tt.args.phone, GetMockSecretGenerator(t), tt.args.returnCode)
got, err := c.changeUserPhoneWithGenerator(context.Background(), tt.args.userID, tt.args.phone, GetMockSecretGenerator(t), tt.args.returnCode)
require.ErrorIs(t, err, tt.wantErr)
assert.Equal(t, got, tt.want)
})
}
}
func TestCommands_resendUserPhoneCodeWithGenerator(t *testing.T) {
type fields struct {
eventstore *eventstore.Eventstore
checkPermission domain.PermissionCheck
}
type args struct {
userID string
returnCode bool
}
tests := []struct {
name string
fields fields
args args
want *domain.Phone
wantErr error
}{
{
name: "missing user",
fields: fields{
eventstore: eventstoreExpect(t),
},
args: args{
userID: "",
returnCode: false,
},
wantErr: zerrors.ThrowInvalidArgument(nil, "COMMAND-xP292j", "Errors.User.Phone.IDMissing"),
},
{
name: "missing permission",
fields: fields{
eventstore: eventstoreExpect(
t,
expectFilter(
eventFromEventPusher(
func() eventstore.Command {
event := user.NewHumanAddedEvent(context.Background(),
&user.NewAggregate("user1", "org1").Aggregate,
"username",
"firstname",
"lastname",
"nickname",
"displayname",
language.German,
domain.GenderUnspecified,
"email@test.ch",
true,
)
event.AddPhoneData("+41791234567")
return event
}(),
),
),
),
checkPermission: newMockPermissionCheckNotAllowed(),
},
args: args{
userID: "user1",
returnCode: false,
},
wantErr: zerrors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied"),
},
{
name: "no code",
fields: fields{
eventstore: eventstoreExpect(
t,
expectFilter(
eventFromEventPusher(
func() eventstore.Command {
event := user.NewHumanAddedEvent(context.Background(),
&user.NewAggregate("user1", "org1").Aggregate,
"username",
"firstname",
"lastname",
"nickname",
"displayname",
language.German,
domain.GenderUnspecified,
"email@test.ch",
true,
)
event.AddPhoneData("+41791234567")
return event
}(),
),
),
),
checkPermission: newMockPermissionCheckAllowed(),
},
args: args{
userID: "user1",
returnCode: false,
},
wantErr: zerrors.ThrowPreconditionFailed(nil, "PHONE-5xrra88eq8", "Errors.User.Code.Empty"),
},
{
name: "resend",
fields: fields{
eventstore: eventstoreExpect(
t,
expectFilter(
eventFromEventPusher(
func() eventstore.Command {
event := user.NewHumanAddedEvent(context.Background(),
&user.NewAggregate("user1", "org1").Aggregate,
"username",
"firstname",
"lastname",
"nickname",
"displayname",
language.German,
domain.GenderUnspecified,
"email@test.ch",
true,
)
event.AddPhoneData("+41791234567")
return event
}(),
),
eventFromEventPusher(
user.NewHumanPhoneCodeAddedEventV2(context.Background(),
&user.NewAggregate("user1", "org1").Aggregate,
&crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte("a"),
},
time.Hour*1,
true,
),
),
),
expectPush(
user.NewHumanPhoneCodeAddedEventV2(context.Background(),
&user.NewAggregate("user1", "org1").Aggregate,
&crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte("a"),
},
time.Hour*1,
false,
),
),
),
checkPermission: newMockPermissionCheckAllowed(),
},
args: args{
userID: "user1",
returnCode: false,
},
want: &domain.Phone{
ObjectRoot: models.ObjectRoot{
AggregateID: "user1",
ResourceOwner: "org1",
},
PhoneNumber: "+41791234567",
IsPhoneVerified: false,
},
},
{
name: "return code",
fields: fields{
eventstore: eventstoreExpect(
t,
expectFilter(
eventFromEventPusher(
func() eventstore.Command {
event := user.NewHumanAddedEvent(context.Background(),
&user.NewAggregate("user1", "org1").Aggregate,
"username",
"firstname",
"lastname",
"nickname",
"displayname",
language.German,
domain.GenderUnspecified,
"email@test.ch",
true,
)
event.AddPhoneData("+41791234567")
return event
}(),
),
eventFromEventPusher(
user.NewHumanPhoneCodeAddedEventV2(context.Background(),
&user.NewAggregate("user1", "org1").Aggregate,
&crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte("a"),
},
time.Hour*1,
true,
),
),
),
expectPush(
user.NewHumanPhoneCodeAddedEventV2(context.Background(),
&user.NewAggregate("user1", "org1").Aggregate,
&crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte("a"),
},
time.Hour*1,
true,
),
),
),
checkPermission: newMockPermissionCheckAllowed(),
},
args: args{
userID: "user1",
returnCode: true,
},
want: &domain.Phone{
ObjectRoot: models.ObjectRoot{
AggregateID: "user1",
ResourceOwner: "org1",
},
PhoneNumber: "+41791234567",
IsPhoneVerified: false,
PlainCode: gu.Ptr("a"),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := &Commands{
eventstore: tt.fields.eventstore,
checkPermission: tt.fields.checkPermission,
}
got, err := c.resendUserPhoneCodeWithGenerator(context.Background(), tt.args.userID, GetMockSecretGenerator(t), tt.args.returnCode)
require.ErrorIs(t, err, tt.wantErr)
assert.Equal(t, got, tt.want)
})

View File

@ -141,7 +141,7 @@ func (a *OIDCApp) IsValid() bool {
func (a *OIDCApp) OriginsValid() bool {
for _, origin := range a.AdditionalOrigins {
if !http_util.IsOrigin(origin) {
if !http_util.IsOrigin(strings.TrimSpace(origin)) {
return false
}
}

28
internal/domain/target.go Normal file
View File

@ -0,0 +1,28 @@
package domain
type TargetType uint
const (
TargetTypeUnspecified TargetType = iota
TargetTypeWebhook
TargetTypeRequestResponse
TargetTypeStateCount
)
type TargetState int32
const (
TargetUnspecified TargetState = iota
TargetActive
TargetRemoved
targetStateCount
)
func (s TargetState) Valid() bool {
return s >= 0 && s < targetStateCount
}
func (s TargetState) Exists() bool {
return s != TargetUnspecified && s != TargetRemoved
}

View File

@ -26,6 +26,7 @@ import (
"github.com/zitadel/zitadel/internal/repository/idp"
"github.com/zitadel/zitadel/pkg/grpc/admin"
"github.com/zitadel/zitadel/pkg/grpc/auth"
execution "github.com/zitadel/zitadel/pkg/grpc/execution/v3alpha"
mgmt "github.com/zitadel/zitadel/pkg/grpc/management"
object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta"
oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2beta"
@ -38,28 +39,30 @@ import (
)
type Client struct {
CC *grpc.ClientConn
Admin admin.AdminServiceClient
Mgmt mgmt.ManagementServiceClient
Auth auth.AuthServiceClient
UserV2 user.UserServiceClient
SessionV2 session.SessionServiceClient
OIDCv2 oidc_pb.OIDCServiceClient
OrgV2 organisation.OrganizationServiceClient
System system.SystemServiceClient
CC *grpc.ClientConn
Admin admin.AdminServiceClient
Mgmt mgmt.ManagementServiceClient
Auth auth.AuthServiceClient
UserV2 user.UserServiceClient
SessionV2 session.SessionServiceClient
OIDCv2 oidc_pb.OIDCServiceClient
OrgV2 organisation.OrganizationServiceClient
System system.SystemServiceClient
ExecutionV3 execution.ExecutionServiceClient
}
func newClient(cc *grpc.ClientConn) Client {
return Client{
CC: cc,
Admin: admin.NewAdminServiceClient(cc),
Mgmt: mgmt.NewManagementServiceClient(cc),
Auth: auth.NewAuthServiceClient(cc),
UserV2: user.NewUserServiceClient(cc),
SessionV2: session.NewSessionServiceClient(cc),
OIDCv2: oidc_pb.NewOIDCServiceClient(cc),
OrgV2: organisation.NewOrganizationServiceClient(cc),
System: system.NewSystemServiceClient(cc),
CC: cc,
Admin: admin.NewAdminServiceClient(cc),
Mgmt: mgmt.NewManagementServiceClient(cc),
Auth: auth.NewAuthServiceClient(cc),
UserV2: user.NewUserServiceClient(cc),
SessionV2: session.NewSessionServiceClient(cc),
OIDCv2: oidc_pb.NewOIDCServiceClient(cc),
OrgV2: organisation.NewOrganizationServiceClient(cc),
System: system.NewSystemServiceClient(cc),
ExecutionV3: execution.NewExecutionServiceClient(cc),
}
}
@ -503,3 +506,20 @@ func (s *Tester) CreateProjectMembership(t *testing.T, ctx context.Context, proj
})
require.NoError(t, err)
}
func (s *Tester) CreateTarget(ctx context.Context, t *testing.T) *execution.CreateTargetResponse {
target, err := s.Client.ExecutionV3.CreateTarget(ctx, &execution.CreateTargetRequest{
Name: fmt.Sprint(time.Now().UnixNano() + 1),
TargetType: &execution.CreateTargetRequest_RestWebhook{
RestWebhook: &execution.SetRESTWebhook{
Url: "https://example.com",
},
},
Timeout: durationpb.New(10 * time.Second),
ExecutionType: &execution.CreateTargetRequest_InterruptOnError{
InterruptOnError: true,
},
})
require.NoError(t, err)
return target
}

View File

@ -0,0 +1,18 @@
package target
import "github.com/zitadel/zitadel/internal/eventstore"
const (
AggregateType = "target"
AggregateVersion = "v1"
)
func NewAggregate(aggrID, instanceID string) *eventstore.Aggregate {
return &eventstore.Aggregate{
ID: aggrID,
Type: AggregateType,
ResourceOwner: instanceID,
InstanceID: instanceID,
Version: AggregateVersion,
}
}

View File

@ -0,0 +1,25 @@
package target
import (
"github.com/zitadel/zitadel/internal/eventstore"
)
const (
UniqueTarget = "target"
DuplicateTarget = "Errors.Target.AlreadyExists"
)
func NewAddUniqueConstraint(name string) *eventstore.UniqueConstraint {
return eventstore.NewAddEventUniqueConstraint(
UniqueTarget,
name,
DuplicateTarget,
)
}
func NewRemoveUniqueConstraint(name string) *eventstore.UniqueConstraint {
return eventstore.NewRemoveUniqueConstraint(
UniqueTarget,
name,
)
}

View File

@ -0,0 +1,9 @@
package target
import "github.com/zitadel/zitadel/internal/eventstore"
func init() {
eventstore.RegisterFilterEventMapper(AggregateType, AddedEventType, AddedEventMapper)
eventstore.RegisterFilterEventMapper(AggregateType, ChangedEventType, ChangedEventMapper)
eventstore.RegisterFilterEventMapper(AggregateType, RemovedEventType, RemovedEventMapper)
}

View File

@ -0,0 +1,199 @@
package target
import (
"context"
"time"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/zerrors"
)
const (
eventTypePrefix eventstore.EventType = "target."
AddedEventType = eventTypePrefix + "added"
ChangedEventType = eventTypePrefix + "changed"
RemovedEventType = eventTypePrefix + "removed"
)
type AddedEvent struct {
*eventstore.BaseEvent `json:"-"`
Name string `json:"name"`
TargetType domain.TargetType `json:"targetType"`
URL string `json:"url"`
Timeout time.Duration `json:"timeout"`
Async bool `json:"async"`
InterruptOnError bool `json:"interruptOnError"`
}
func (e *AddedEvent) SetBaseEvent(b *eventstore.BaseEvent) {
e.BaseEvent = b
}
func (e *AddedEvent) Payload() any {
return e
}
func (e *AddedEvent) UniqueConstraints() []*eventstore.UniqueConstraint {
return []*eventstore.UniqueConstraint{NewAddUniqueConstraint(e.Name)}
}
func NewAddedEvent(
ctx context.Context,
aggregate *eventstore.Aggregate,
name string,
targetType domain.TargetType,
url string,
timeout time.Duration,
async bool,
interruptOnError bool,
) *AddedEvent {
return &AddedEvent{
eventstore.NewBaseEventForPush(
ctx, aggregate, AddedEventType,
),
name, targetType, url, timeout, async, interruptOnError}
}
func AddedEventMapper(event eventstore.Event) (eventstore.Event, error) {
added := &AddedEvent{
BaseEvent: eventstore.BaseEventFromRepo(event),
}
err := event.Unmarshal(added)
if err != nil {
return nil, zerrors.ThrowInternal(err, "TARGET-fx8f8yfbn1", "unable to unmarshal target added")
}
return added, nil
}
type ChangedEvent struct {
*eventstore.BaseEvent `json:"-"`
Name *string `json:"name,omitempty"`
TargetType *domain.TargetType `json:"targetType,omitempty"`
URL *string `json:"url,omitempty"`
Timeout *time.Duration `json:"timeout,omitempty"`
Async *bool `json:"async,omitempty"`
InterruptOnError *bool `json:"interruptOnError,omitempty"`
oldName string
}
func (e *ChangedEvent) Payload() interface{} {
return e
}
func (e *ChangedEvent) UniqueConstraints() []*eventstore.UniqueConstraint {
if e.oldName == "" {
return nil
}
return []*eventstore.UniqueConstraint{
NewRemoveUniqueConstraint(e.oldName),
NewAddUniqueConstraint(*e.Name),
}
}
func NewChangedEvent(
ctx context.Context,
aggregate *eventstore.Aggregate,
changes []Changes,
) *ChangedEvent {
changeEvent := &ChangedEvent{
BaseEvent: eventstore.NewBaseEventForPush(
ctx,
aggregate,
ChangedEventType,
),
}
for _, change := range changes {
change(changeEvent)
}
return changeEvent
}
type Changes func(event *ChangedEvent)
func ChangeName(oldName, name string) func(event *ChangedEvent) {
return func(e *ChangedEvent) {
e.Name = &name
e.oldName = oldName
}
}
func ChangeTargetType(targetType domain.TargetType) func(event *ChangedEvent) {
return func(e *ChangedEvent) {
e.TargetType = &targetType
}
}
func ChangeURL(url string) func(event *ChangedEvent) {
return func(e *ChangedEvent) {
e.URL = &url
}
}
func ChangeTimeout(timeout time.Duration) func(event *ChangedEvent) {
return func(e *ChangedEvent) {
e.Timeout = &timeout
}
}
func ChangeAsync(async bool) func(event *ChangedEvent) {
return func(e *ChangedEvent) {
e.Async = &async
}
}
func ChangeInterruptOnError(interruptOnError bool) func(event *ChangedEvent) {
return func(e *ChangedEvent) {
e.InterruptOnError = &interruptOnError
}
}
func ChangedEventMapper(event eventstore.Event) (eventstore.Event, error) {
changed := &ChangedEvent{
BaseEvent: eventstore.BaseEventFromRepo(event),
}
err := event.Unmarshal(changed)
if err != nil {
return nil, zerrors.ThrowInternal(err, "TARGET-w6402p4ek7", "unable to unmarshal target changed")
}
return changed, nil
}
type RemovedEvent struct {
*eventstore.BaseEvent `json:"-"`
name string
}
func (e *RemovedEvent) SetBaseEvent(b *eventstore.BaseEvent) {
e.BaseEvent = b
}
func (e *RemovedEvent) Payload() any {
return e
}
func (e *RemovedEvent) UniqueConstraints() []*eventstore.UniqueConstraint {
return []*eventstore.UniqueConstraint{NewRemoveUniqueConstraint(e.name)}
}
func NewRemovedEvent(ctx context.Context, aggregate *eventstore.Aggregate, name string) *RemovedEvent {
return &RemovedEvent{eventstore.NewBaseEventForPush(ctx, aggregate, RemovedEventType), name}
}
func RemovedEventMapper(event eventstore.Event) (eventstore.Event, error) {
removed := &RemovedEvent{
BaseEvent: eventstore.BaseEventFromRepo(event),
}
err := event.Unmarshal(removed)
if err != nil {
return nil, zerrors.ThrowInternal(err, "TARGET-0kuc12c7bc", "unable to unmarshal target removed")
}
return removed, nil
}

View File

@ -130,6 +130,7 @@ Errors:
NotFound: Кодът не е намерен
Expired: Кодът е изтекъл
GeneratorAlgNotSupported: Неподдържан генераторен алгоритъм
Invalid: кодът е невалиден
Password:
NotFound: Паролата не е намерена
Empty: Паролата е празна
@ -551,6 +552,11 @@ Errors:
NotExisting: Функцията не съществува
TypeNotSupported: Типът функция не се поддържа
InvalidValue: Невалидна стойност за тази функция
Target:
Invalid: Целта е невалидна
NoTimeout: Целта няма време за изчакване
InvalidURL: Целта има невалиден URL адрес
NotFound: Целта не е намерена
AggregateTypes:
action: Действие
@ -562,8 +568,13 @@ AggregateTypes:
usergrant: Предоставяне на потребител
quota: Квота
feature: Особеност
target: Целта
EventTypes:
target:
added: Целта е създадена
changed: Целта е променена
removed: Целта е изтрита
user:
added: Добавен потребител
selfregistered: Потребителят се регистрира сам

View File

@ -127,6 +127,7 @@ Errors:
NotFound: Kód nenalezen
Expired: Kód vypršel
GeneratorAlgNotSupported: Nepodporovaný algoritmus generátoru
Invalid: Kód je neplatný
Password:
NotFound: Heslo nenalezeno
Empty: Heslo je prázdné
@ -531,6 +532,11 @@ Errors:
NotExisting: Funkce neexistuje
TypeNotSupported: Typ funkce není podporován
InvalidValue: Neplatná hodnota pro tuto funkci
Target:
Invalid: Cíl je neplatný
NoTimeout: Cíl nemá časový limit
InvalidURL: Cíl má neplatnou adresu URL
NotFound: Cíl nenalezen
AggregateTypes:
action: Akce
@ -542,8 +548,13 @@ AggregateTypes:
usergrant: Uživatelský grant
quota: Kvóta
feature: Funkce
target: Cíl
EventTypes:
target:
added: Cíl vytvořen
changed: Cíl změněn
removed: Cíl smazán
user:
added: Uživatel přidán
selfregistered: Uživatel se zaregistroval sám

View File

@ -128,6 +128,7 @@ Errors:
NotFound: Code konnte nicht gefunden werden
Expired: Code ist abgelaufen
GeneratorAlgNotSupported: Generator Algorithmus wird nicht unterstützt
Invalid: Code ist nicht gültig
Password:
NotFound: Password nicht gefunden
Empty: Passwort ist leer
@ -534,6 +535,11 @@ Errors:
NotExisting: Feature existiert nicht
TypeNotSupported: Feature Typ wird nicht unterstützt
InvalidValue: Ungültiger Wert für dieses Feature
Target:
Invalid: Ziel ist ungültig
NoTimeout: Ziel hat keinen Timeout
InvalidURL: Ziel hat eine ungültige URL
NotFound: Ziel nicht gefunden
AggregateTypes:
action: Action
@ -545,8 +551,13 @@ AggregateTypes:
usergrant: Benutzerberechtigung
quota: Kontingent
feature: Feature
target: Ziel
EventTypes:
target:
added: Ziel erstellt
changed: Ziel geändert
removed: Ziel gelöscht
user:
added: Benutzer hinzugefügt
selfregistered: Benutzer hat sich selbst registriert

View File

@ -128,6 +128,7 @@ Errors:
NotFound: Code not found
Expired: Code is expired
GeneratorAlgNotSupported: Unsupported generator algorithm
Invalid: Code is invalid
Password:
NotFound: Password not found
Empty: Password is empty
@ -534,6 +535,11 @@ Errors:
NotExisting: Feature does not exist
TypeNotSupported: Feature type is not supported
InvalidValue: Invalid value for this feature
Target:
Invalid: Target is invalid
NoTimeout: Target has no timeout
InvalidURL: Target has an invalid URL
NotFound: Target not found
AggregateTypes:
action: Action
@ -545,8 +551,13 @@ AggregateTypes:
usergrant: User grant
quota: Quota
feature: Feature
target: Target
EventTypes:
target:
added: Target created
changed: Target changed
removed: Target deleted
user:
added: User added
selfregistered: User registered himself

View File

@ -128,6 +128,7 @@ Errors:
NotFound: Código no encontrado
Expired: El código ha caducado
GeneratorAlgNotSupported: Algoritmo generador no soportado
Invalid: El código no es válido
Password:
NotFound: Contraseña no encontrada
Empty: La contraseña está vacía
@ -534,6 +535,11 @@ Errors:
NotExisting: La característica no existe
TypeNotSupported: El tipo de característica no es compatible
InvalidValue: Valor no válido para esta característica
Target:
Invalid: El objetivo no es válido
NoTimeout: El objetivo no tiene tiempo de espera
InvalidURL: El objetivo tiene una URL no válida
NotFound: El objetivo no encontrado
AggregateTypes:
action: Acción
@ -545,8 +551,13 @@ AggregateTypes:
usergrant: Concesión de usuario
quota: Cuota
feature: Característica
target: Objectivo
EventTypes:
target:
added: Objetivo creado
changed: Objetivo cambiado
removed: Objetivo eliminado
user:
added: Usuario añadido
selfregistered: El usuario se registró por sí mismo

View File

@ -128,6 +128,7 @@ Errors:
NotFound: Code non trouvé
Expired: Le code est expiré
GeneratorAlgNotSupported: Algorithme de générateur non pris en charge
Invalid: Le code n'est pas valide
Password:
NotFound: Mot de passe non trouvé
Empty: Le mot de passe est vide
@ -534,6 +535,11 @@ Errors:
NotExisting: La fonctionnalité n'existe pas
TypeNotSupported: Le type de fonctionnalité n'est pas pris en charge
InvalidValue: Valeur non valide pour cette fonctionnalité
Target:
Invalid: La cible n'est pas valide
NoTimeout: La cible n'a pas de délai d'attente
InvalidURL: La cible a une URL non valide
NotFound: La cible introuvable
AggregateTypes:
action: Action
@ -545,8 +551,13 @@ AggregateTypes:
usergrant: Subvention de l'utilisateur
quota: Contingent
feature: Fonctionnalité
target: Cible
EventTypes:
target:
added: Cible créée
changed: Cible modifiée
removed: Cible supprimée
user:
added: Utilisateur ajouté
selfregistered: L'utilisateur s'est enregistré lui-même

View File

@ -128,6 +128,7 @@ Errors:
NotFound: Codice non trovato
Expired: Il codice è scaduto
GeneratorAlgNotSupported: L'algoritmo del generatore non è supportato
Invalid: Il codice non è valido
Password:
NotFound: Password non trovato
Empty: La password è vuota
@ -535,6 +536,11 @@ Errors:
NotExisting: La funzionalità non esiste
TypeNotSupported: Il tipo di funzionalità non è supportato
InvalidValue: Valore non valido per questa funzionalità
Target:
Invalid: Il target non è valido
NoTimeout: Il target non ha timeout
InvalidURL: La destinazione ha un URL non valido
NotFound: Obiettivo non trovato
AggregateTypes:
action: Azione
@ -546,8 +552,13 @@ AggregateTypes:
usergrant: Sovvenzione utente
quota: Quota
feature: Funzionalità
target: Bersaglio
EventTypes:
target:
added: Obiettivo creato
changed: Obiettivo cambiato
removed: Obiettivo eliminato
user:
added: Utente aggiunto
selfregistered: L'utente si è registrato

View File

@ -120,6 +120,7 @@ Errors:
NotFound: コードが見つかりません
Expired: 有効期限切れのコードです
GeneratorAlgNotSupported: サポートされていない生成アルゴリズムです
Invalid: コードが無効
Password:
NotFound: パスワードが見つかりません
Empty: パスワードは空です
@ -523,6 +524,11 @@ Errors:
NotExisting: 機能が存在しません
TypeNotSupported: 機能タイプはサポートされていません
InvalidValue: この機能には無効な値です
Target:
Invalid: ターゲットが無効です
NoTimeout: ターゲットにはタイムアウトがありません
InvalidURL: ターゲットに無効な URL があります
NotFound: ターゲットが見つかりません
AggregateTypes:
action: アクション
@ -534,8 +540,13 @@ AggregateTypes:
usergrant: ユーザーグラント
quota: クォータ
feature: 特徴
target: 目標
EventTypes:
target:
added: ターゲットが作成されました
changed: ターゲットが変更されました
removed: ターゲットが削除されました
user:
added: ユーザーの追加
selfregistered: ユーザー自身の登録

View File

@ -534,6 +534,11 @@ Errors:
NotExisting: Функцијата не постои
TypeNotSupported: Типот на функција не е поддржан
InvalidValue: Неважечка вредност за оваа функција
Target:
Invalid: Целта е неважечка
NoTimeout: Целта нема тајмаут
InvalidURL: Целта има неважечка URL-адреса
NotFound: Целта не е пронајдена
AggregateTypes:
action: Акција
@ -545,8 +550,13 @@ AggregateTypes:
usergrant: Овластување на корисник
quota: Квота
feature: Карактеристика
target: Цел
EventTypes:
target:
added: Целта е избришана
changed: Целта е променета
removed: Целта е избришана
user:
added: Додаден корисник
selfregistered: Корисникот се регистрираше сам

View File

@ -128,6 +128,7 @@ Errors:
NotFound: Code niet gevonden
Expired: Code is verlopen
GeneratorAlgNotSupported: Generator algoritme wordt niet ondersteund
Invalid: Code is ongeldig
Password:
NotFound: Wachtwoord niet gevonden
Empty: Wachtwoord is leeg
@ -534,6 +535,11 @@ Errors:
NotExisting: Functie bestaat niet
TypeNotSupported: Functie type wordt niet ondersteund
InvalidValue: Ongeldige waarde voor deze functie
Target:
Invalid: Doel is ongeldig
NoTimeout: Doel heeft geen time-out
InvalidURL: Doel heeft een ongeldige URL
NotFound: Doel niet gevonden
AggregateTypes:
action: Actie
@ -545,8 +551,13 @@ AggregateTypes:
usergrant: Gebruikerstoekenning
quota: Quota
feature: Functie
target: Doel
EventTypes:
target:
added: Doel gemaakt
changed: Doel gewijzigd
removed: Doel verwijderd
user:
added: Gebruiker toegevoegd
selfregistered: Gebruiker heeft zichzelf geregistreerd

View File

@ -128,6 +128,7 @@ Errors:
NotFound: Kod nie znaleziony
Expired: Kod jest przedawniony
GeneratorAlgNotSupported: Nieobsługiwany algorytm generatora
Invalid: Kod jest nieprawidłowy
Password:
NotFound: Hasło nie znalezione
Empty: Hasło jest puste
@ -534,6 +535,11 @@ Errors:
NotExisting: Funkcja nie istnieje
TypeNotSupported: Typ funkcji nie jest obsługiwany
InvalidValue: Nieprawidłowa wartość dla tej funkcji
Target:
Invalid: Cel jest nieprawidłowy
NoTimeout: Cel nie ma limitu czasu
InvalidURL: Cel ma nieprawidłowy adres URL
NotFound: Nie znaleziono celu
AggregateTypes:
action: Działanie
@ -545,8 +551,13 @@ AggregateTypes:
usergrant: Uprawnienie użytkownika
quota: Limit
feature: Funkcja
target: Cel
EventTypes:
target:
added: Cel został utworzony
changed: Cel zmieniony
removed: Cel usunięty
user:
added: Użytkownik dodany
selfregistered: Użytkownik zarejestrował się

View File

@ -128,6 +128,7 @@ Errors:
NotFound: Código não encontrado
Expired: Código expirou
GeneratorAlgNotSupported: Algoritmo do gerador não suportado
Invalid: Código é inválido
Password:
NotFound: Senha não encontrada
Empty: Senha está vazia
@ -528,6 +529,11 @@ Errors:
NotExisting: O recurso não existe
TypeNotSupported: O tipo de recurso não é compatível
InvalidValue: Valor inválido para este recurso
Target:
Invalid: A meta é inválida
NoTimeout: O destino não tem tempo limite
InvalidURL: O destino tem um URL inválido
NotFound: Destino não encontrado
AggregateTypes:
action: Ação
@ -539,8 +545,13 @@ AggregateTypes:
usergrant: Concessão de usuário
quota: Cota
feature: Recurso
target: objetivo
EventTypes:
target:
added: Destino criado
changed: Destino alterada
removed: Destino excluído
user:
added: Usuário adicionado
selfregistered: Usuário se registrou

View File

@ -127,6 +127,7 @@ Errors:
NotFound: Код не найден
Expired: Срок действия кода истек
GeneratorAlgNotSupported: Неподдерживаемый алгоритм генератора
Invalid: Код недействителен
Password:
NotFound: Пароль не найден
Empty: Пароль пуст
@ -518,6 +519,16 @@ Errors:
Invalid: Токен недействителен
Expired: Срок действия токена истек
InvalidClient: Токен не был выпущен для этого клиента
Feature:
NotExisting: ункция не существует
TypeNotSupported: Тип объекта не поддерживается
InvalidValue: Недопустимое значение для этой функции.
Target:
Invalid: Цель недействительна.
NoTimeout: У цели нет тайм-аута
InvalidURL: Цель имеет неверный URL-адрес
NotFound: Цель не найдена
AggregateTypes:
action: Действие
instance: Пример
@ -527,7 +538,14 @@ AggregateTypes:
user: Пользователь
usergrant: Разрешение пользователя
quota: Квота
feature: Особенность
target: мишень
EventTypes:
target:
added: Цель создана
changed: Цель изменена
removed: Цель удалена.
user:
added: Добавлено пользователем
selfregistered: Пользователь зарегистрировался сам

View File

@ -128,6 +128,7 @@ Errors:
NotFound: 验证码不存在
Expired: 验证码已过期
GeneratorAlgNotSupported: 不支持的生成器算法
Invalid: 代码无效
Password:
NotFound: 未找到密码
Empty: 密码为空
@ -534,6 +535,11 @@ Errors:
NotExisting: 功能不存在
TypeNotSupported: 不支持功能类型
InvalidValue: 此功能的值无效
Target:
Invalid: 目标无效
NoTimeout: 目标没有超时
InvalidURL: 目标的 URL 无效
NotFound: 未找到目标
AggregateTypes:
action: 动作
@ -545,8 +551,13 @@ AggregateTypes:
usergrant: 用户授权
quota: 配额
feature: 特征
target:
EventTypes:
target:
added: 目标已创建
changed: 目标改变
removed: 目标已删除
user:
added: 已添加用户
selfregistered: 自注册用户

View File

@ -0,0 +1,293 @@
syntax = "proto3";
package zitadel.execution.v3alpha;
import "google/api/annotations.proto";
import "google/api/field_behavior.proto";
import "google/protobuf/duration.proto";
import "google/protobuf/struct.proto";
import "protoc-gen-openapiv2/options/annotations.proto";
import "validate/validate.proto";
import "zitadel/execution/v3alpha/target.proto";
import "zitadel/object/v2beta/object.proto";
import "zitadel/protoc_gen_zitadel/v2/options.proto";
option go_package = "github.com/zitadel/zitadel/pkg/grpc/execution/v3alpha;execution";
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger) = {
info: {
title: "Execution Service";
version: "3.0-alpha";
description: "This API is intended to manage custom executions (previously known as actions) in a ZITADEL instance. This project is in alpha state. It can AND will continue breaking until the services provide the same functionality as the current actions.";
contact:{
name: "ZITADEL"
url: "https://zitadel.com"
email: "hi@zitadel.com"
}
license: {
name: "Apache 2.0",
url: "https://github.com/zitadel/zitadel/blob/main/LICENSE";
};
};
schemes: HTTPS;
schemes: HTTP;
consumes: "application/json";
consumes: "application/grpc";
produces: "application/json";
produces: "application/grpc";
consumes: "application/grpc-web+proto";
produces: "application/grpc-web+proto";
host: "$CUSTOM-DOMAIN";
base_path: "/";
external_docs: {
description: "Detailed information about ZITADEL",
url: "https://zitadel.com/docs"
}
security_definitions: {
security: {
key: "OAuth2";
value: {
type: TYPE_OAUTH2;
flow: FLOW_ACCESS_CODE;
authorization_url: "$CUSTOM-DOMAIN/oauth/v2/authorize";
token_url: "$CUSTOM-DOMAIN/oauth/v2/token";
scopes: {
scope: {
key: "openid";
value: "openid";
}
scope: {
key: "urn:zitadel:iam:org:project:id:zitadel:aud";
value: "urn:zitadel:iam:org:project:id:zitadel:aud";
}
}
}
}
}
security: {
security_requirement: {
key: "OAuth2";
value: {
scope: "openid";
scope: "urn:zitadel:iam:org:project:id:zitadel:aud";
}
}
}
responses: {
key: "403";
value: {
description: "Returned when the user does not have permission to access the resource.";
schema: {
json_schema: {
ref: "#/definitions/rpcStatus";
}
}
}
}
responses: {
key: "404";
value: {
description: "Returned when the resource does not exist.";
schema: {
json_schema: {
ref: "#/definitions/rpcStatus";
}
}
}
}
};
service ExecutionService {
// Create a target
//
// Create a new target, which can be used in executions.
rpc CreateTarget (CreateTargetRequest) returns (CreateTargetResponse) {
option (google.api.http) = {
post: "/v3alpha/targets"
body: "*"
};
option (zitadel.protoc_gen_zitadel.v2.options) = {
auth_option: {
permission: "execution.target.write"
}
http_response: {
success_code: 201
}
};
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
responses: {
key: "201";
value: {
description: "Target successfully created";
schema: {
json_schema: {
ref: "#/definitions/v3alphaCreateTargetResponse";
}
}
};
};
};
}
// Update a target
//
// Update an existing target.
rpc UpdateTarget (UpdateTargetRequest) returns (UpdateTargetResponse) {
option (google.api.http) = {
put: "/v3alpha/targets/{target_id}"
body: "*"
};
option (zitadel.protoc_gen_zitadel.v2.options) = {
auth_option: {
permission: "execution.target.write"
}
};
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
responses: {
key: "200";
value: {
description: "Target successfully updated";
};
};
};
}
// Delete a target
//
// Delete an existing target. This will remove it from any configured execution as well.
rpc DeleteTarget (DeleteTargetRequest) returns (DeleteTargetResponse) {
option (google.api.http) = {
delete: "/v3alpha/targets/{target_id}"
};
option (zitadel.protoc_gen_zitadel.v2.options) = {
auth_option: {
permission: "execution.target.delete"
}
};
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
responses: {
key: "200";
value: {
description: "Target successfully deleted";
};
};
};
}
}
message CreateTargetRequest {
// Unique name of the target.
string name = 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: "\"ip_allow_list\"";
}
];
// Defines the target type and how the response of the target is treated.
oneof target_type {
option (validate.required) = true;
SetRESTWebhook rest_webhook = 2;
SetRESTRequestResponse rest_request_response = 3;
}
// Timeout defines the duration until ZITADEL cancels the execution.
google.protobuf.Duration timeout = 4 [
(validate.rules).duration = {gt: {seconds: 0}, required: true},
(google.api.field_behavior) = REQUIRED,
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
example: "\"10s\"";
}
];
oneof execution_type {
// Set the execution to run asynchronously.
bool is_async = 5;
// Define if any error stops the whole execution. By default the process continues as normal.
bool interrupt_on_error = 6;
}
}
message CreateTargetResponse {
// ID is the read-only unique identifier of the target.
string id = 1;
// Details provide some base information (such as the last change date) of the target.
zitadel.object.v2beta.Details details = 2;
}
message UpdateTargetRequest {
// unique identifier of the target.
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\"";
}
];
// Optionally change the unique name of the target.
optional string name = 2 [
(validate.rules).string = {min_len: 1, max_len: 200},
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
min_length: 1,
max_length: 200,
example: "\"ip_allow_list\"";
}
];
// Optionally change the target type and how the response of the target is treated,
// or its target URL.
oneof target_type {
SetRESTWebhook rest_webhook = 3;
SetRESTRequestResponse rest_request_response = 4;
}
// Optionally change the timeout, which defines the duration until ZITADEL cancels the execution.
optional google.protobuf.Duration timeout = 5 [
(validate.rules).duration = {gt: {seconds: 0}},
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
example: "\"10s\"";
}
];
oneof execution_type {
// Set the execution to run asynchronously.
bool is_async = 6;
// Define if any error stops the whole execution. By default the process continues as normal.
bool interrupt_on_error = 7;
}
}
message UpdateTargetResponse {
// Details provide some base information (such as the last change date) of the target.
zitadel.object.v2beta.Details details = 1;
}
message DeleteTargetRequest {
// unique identifier of the target.
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\"";
}
];
}
message DeleteTargetResponse {
// Details provide some base information (such as the last change date) of the target.
zitadel.object.v2beta.Details details = 1;
}

View File

@ -0,0 +1,38 @@
syntax = "proto3";
package zitadel.execution.v3alpha;
import "google/api/annotations.proto";
import "google/api/field_behavior.proto";
import "google/protobuf/duration.proto";
import "google/protobuf/struct.proto";
import "protoc-gen-openapiv2/options/annotations.proto";
import "validate/validate.proto";
import "zitadel/object/v2beta/object.proto";
import "zitadel/protoc_gen_zitadel/v2/options.proto";
option go_package = "github.com/zitadel/zitadel/pkg/grpc/execution/v3alpha;execution";
message SetRESTWebhook {
string url = 1 [
(validate.rules).string = {min_len: 1, max_len: 1000, uri: true},
(google.api.field_behavior) = REQUIRED,
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
min_length: 1,
max_length: 1000,
example: "\"https://example.com/hooks/ip_check\"";
}
];
}
message SetRESTRequestResponse {
string url = 1 [
(validate.rules).string = {min_len: 1, max_len: 1000, uri: true},
(google.api.field_behavior) = REQUIRED,
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
min_length: 1,
max_length: 1000,
example: "\"https://example.com/hooks/ip_check\"";
}
];
}

View File

@ -0,0 +1,168 @@
syntax = "proto3";
package zitadel.user.schema.v3alpha;
import "google/api/field_behavior.proto";
import "google/protobuf/struct.proto";
import "validate/validate.proto";
import "protoc-gen-openapiv2/options/annotations.proto";
import "zitadel/object/v2beta/object.proto";
option go_package = "github.com/zitadel/zitadel/pkg/grpc/user/schema/v3alpha";
message UserSchema {
// ID is the read-only unique identifier of the schema.
string id = 1 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
example: "\"69629012906488334\""
}
];
// Details provide some base information (such as the last change date) of the schema.
zitadel.object.v2beta.Details details = 2;
// Type is a human readable text describing the schema.
string type = 3 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
example: "\"employees\""
}
];
// Current state of the schema.
State state = 4 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
example: "\"STATE_ACTIVE\""
}
];
// Revision is a read only version of the schema, each update increases the revision.
uint32 revision = 5 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
example: "\"2\""
}
];
// JSON schema representation defining the user.
google.protobuf.Struct schema = 6 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
example: "{\"$schema\":\"https://example.com/user/employees\",\"type\":\"object\",\"properties\":{\"name\":{\"type\":\"string\",\"required\":true},\"description\":{\"type\":\"string\"}}}"
}
];
// Defines the possible types of authenticators.
// This allows creating different user types like human/machine without usage of actions to validate possible authenticators.
// Removal of an authenticator does not remove the authenticator on a user.
repeated AuthenticatorType possible_authenticators = 7 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
example: "[\"AUTHENTICATOR_TYPE_USERNAME\",\"AUTHENTICATOR_TYPE_PASSWORD\",\"AUTHENTICATOR_TYPE_WEBAUTHN\"]";
}
];
}
enum FieldName {
FIELD_NAME_UNSPECIFIED = 0;
FIELD_NAME_TYPE = 1;
FIELD_NAME_STATE = 2;
FIELD_NAME_REVISION = 3;
FIELD_NAME_CREATION_DATE = 4;
}
message SearchQuery {
oneof query {
option (validate.required) = true;
// Union the results of each sub query ('OR').
OrQuery or_query = 1;
// Limit the result to match all sub queries ('AND').
// Note that if you specify multiple queries, they will be implicitly used as andQueries.
// Use the andQuery in combination with orQuery and notQuery.
AndQuery and_query = 2;
// Exclude / Negate the result of the sub query ('NOT').
NotQuery not_query = 3;
// Limit the result to a specific schema type.
TypeQuery type_query = 5;
// Limit the result to a specific state of the schema.
StateQuery state_query = 6;
}
}
message OrQuery {
repeated SearchQuery queries = 1 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
example: "[{\"idQuery\": {\"id\": \"163840776835432705\",\"method\": \"TEXT_QUERY_METHOD_EQUALS\"}},{\"idQuery\": {\"id\": \"163840776835943483\",\"method\": \"TEXT_QUERY_METHOD_EQUALS\"}}]"
}
];
}
message AndQuery {
repeated SearchQuery queries = 1 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
example: "[{\"typeQuery\": {\"id\": \"employees\",\"method\": \"TEXT_QUERY_METHOD_STARTS_WITH\"}},{\"stateQuery\": {\"state\": \"STATE_ACTIVE\"}}]"
}
];
}
message NotQuery {
SearchQuery query = 1 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
example: "{\"stateQuery\": {\"state\": \"STATE_ACTIVE\"}}"
}
];
}
message IDQuery {
// Defines the ID of the user schema to query for.
string 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: "\"163840776835432705\"";
}
];
// Defines which text comparison method used for the id query.
zitadel.object.v2beta.TextQueryMethod method = 2 [
(validate.rules).enum.defined_only = true
];
}
message TypeQuery {
// Defines which type to query for.
string type = 1 [
(validate.rules).string = {max_len: 200},
(google.api.field_behavior) = REQUIRED,
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
max_length: 200,
example: "\"employees\"";
}
];
// Defines which text comparison method used for the type query.
zitadel.object.v2beta.TextQueryMethod method = 2 [
(validate.rules).enum.defined_only = true
];
}
message StateQuery {
// Defines the state to query for.
State state = 1 [
(validate.rules).enum.defined_only = true,
(google.api.field_behavior) = REQUIRED,
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
example: "\"STATE_ACTIVE\""
}
];
}
enum State {
STATE_UNSPECIFIED = 0;
STATE_ACTIVE = 1;
STATE_INACTIVE = 2;
}
enum AuthenticatorType {
AUTHENTICATOR_TYPE_UNSPECIFIED = 0;
AUTHENTICATOR_TYPE_USERNAME = 1;
AUTHENTICATOR_TYPE_PASSWORD = 2;
AUTHENTICATOR_TYPE_WEBAUTHN = 3;
AUTHENTICATOR_TYPE_TOTP = 4;
AUTHENTICATOR_TYPE_OTP_EMAIL = 5;
AUTHENTICATOR_TYPE_OTP_SMS = 6;
AUTHENTICATOR_TYPE_AUTHENTICATION_KEY = 7;
AUTHENTICATOR_TYPE_IDENTITY_PROVIDER = 8;
}

View File

@ -0,0 +1,452 @@
syntax = "proto3";
package zitadel.user.schema.v3alpha;
import "google/api/annotations.proto";
import "google/api/field_behavior.proto";
import "google/protobuf/duration.proto";
import "google/protobuf/struct.proto";
import "protoc-gen-openapiv2/options/annotations.proto";
import "validate/validate.proto";
import "zitadel/object/v2beta/object.proto";
import "zitadel/protoc_gen_zitadel/v2/options.proto";
import "zitadel/user/schema/v3alpha/user_schema.proto";
option go_package = "github.com/zitadel/zitadel/pkg/grpc/user/schema/v3alpha";
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger) = {
info: {
title: "User Schema Service";
version: "3.0-alpha";
description: "This API is intended to manage data schemas for users in a ZITADEL instance. This project is in alpha state. It can AND will continue breaking until the service provides the same functionality as the v1 and v2 user services.";
contact:{
name: "ZITADEL"
url: "https://zitadel.com"
email: "hi@zitadel.com"
}
license: {
name: "Apache 2.0",
url: "https://github.com/zitadel/zitadel/blob/main/LICENSE";
};
};
schemes: HTTPS;
schemes: HTTP;
consumes: "application/json";
produces: "application/json";
consumes: "application/grpc";
produces: "application/grpc";
consumes: "application/grpc-web+proto";
produces: "application/grpc-web+proto";
host: "$CUSTOM-DOMAIN";
base_path: "/";
external_docs: {
description: "Detailed information about ZITADEL",
url: "https://zitadel.com/docs"
}
security_definitions: {
security: {
key: "OAuth2";
value: {
type: TYPE_OAUTH2;
flow: FLOW_ACCESS_CODE;
authorization_url: "$CUSTOM-DOMAIN/oauth/v2/authorize";
token_url: "$CUSTOM-DOMAIN/oauth/v2/token";
scopes: {
scope: {
key: "openid";
value: "openid";
}
scope: {
key: "urn:zitadel:iam:org:project:id:zitadel:aud";
value: "urn:zitadel:iam:org:project:id:zitadel:aud";
}
}
}
}
}
security: {
security_requirement: {
key: "OAuth2";
value: {
scope: "openid";
scope: "urn:zitadel:iam:org:project:id:zitadel:aud";
}
}
}
responses: {
key: "403";
value: {
description: "Returned when the user does not have permission to access the resource.";
schema: {
json_schema: {
ref: "#/definitions/rpcStatus";
}
}
}
}
responses: {
key: "404";
value: {
description: "Returned when the resource does not exist.";
schema: {
json_schema: {
ref: "#/definitions/rpcStatus";
}
}
}
}
};
service UserSchemaService {
// List user schemas
//
// List all matching user schemas. By default, we will return all user schema of your instance. Make sure to include a limit and sorting for pagination.
rpc ListUserSchemas (ListUserSchemasRequest) returns (ListUserSchemasResponse) {
option (google.api.http) = {
post: "/v3alpha/user_schemas/search"
body: "*"
};
option (zitadel.protoc_gen_zitadel.v2.options) = {
auth_option: {
permission: "userschema.read"
}
};
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
responses: {
key: "200";
value: {
description: "A list of all user schema matching the query";
};
};
responses: {
key: "400";
value: {
description: "invalid list query";
schema: {
json_schema: {
ref: "#/definitions/rpcStatus";
};
};
};
};
};
}
// User schema by ID
//
// Returns the user schema identified by the requested ID.
rpc GetUserSchemaByID (GetUserSchemaByIDRequest) returns (GetUserSchemaByIDResponse) {
option (google.api.http) = {
get: "/v3alpha/user_schemas/{id}"
};
option (zitadel.protoc_gen_zitadel.v2.options) = {
auth_option: {
permission: "userschema.read"
}
};
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
responses: {
key: "200"
value: {
description: "Schema successfully retrieved";
}
};
};
}
// Create a user schema
//
// Create the first revision of a new user schema. The schema can then be used on users to store and validate their data.
rpc CreateUserSchema (CreateUserSchemaRequest) returns (CreateUserSchemaResponse) {
option (google.api.http) = {
post: "/v3alpha/user_schemas"
body: "*"
};
option (zitadel.protoc_gen_zitadel.v2.options) = {
auth_option: {
permission: "userschema.write"
}
http_response: {
success_code: 201
}
};
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
responses: {
key: "201";
value: {
description: "Schema successfully created";
schema: {
json_schema: {
ref: "#/definitions/v3alphaCreateUserSchemaResponse";
}
}
};
};
};
}
// Update a user schema
//
// Update an existing user schema to a new revision. Users based on the current revision will not be affected until they are updated.
rpc UpdateUserSchema (UpdateUserSchemaRequest) returns (UpdateUserSchemaResponse) {
option (google.api.http) = {
put: "/v3alpha/user_schemas/{id}"
body: "*"
};
option (zitadel.protoc_gen_zitadel.v2.options) = {
auth_option: {
permission: "userschema.write"
}
};
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
responses: {
key: "200";
value: {
description: "Schema successfully updated";
};
};
};
}
// Deactivate a user schema
//
// Deactivate an existing user schema and change it into a read-only state. Users based on this schema cannot be updated anymore, but are still able to authenticate.
rpc DeactivateUserSchema (DeactivateUserSchemaRequest) returns (DeactivateUserSchemaResponse) {
option (google.api.http) = {
post: "/v3alpha/user_schemas/{id}/deactivate"
};
option (zitadel.protoc_gen_zitadel.v2.options) = {
auth_option: {
permission: "userschema.write"
}
};
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
responses: {
key: "200";
value: {
description: "Schema successfully deactivated";
};
};
};
}
// Reactivate a user schema
//
// Reactivate an previously deactivated user schema and change it into an active state again.
rpc ReactivateUserSchema (ReactivateUserSchemaRequest) returns (ReactivateUserSchemaResponse) {
option (google.api.http) = {
post: "/v3alpha/user_schemas/{id}/reactivate"
};
option (zitadel.protoc_gen_zitadel.v2.options) = {
auth_option: {
permission: "userschema.write"
}
};
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
responses: {
key: "200";
value: {
description: "Schema successfully reactivated";
};
};
};
}
// Delete a user schema
//
// Delete an existing user schema. This operation is only allowed if there are no associated users to it.
rpc DeleteUserSchema (DeleteUserSchemaRequest) returns (DeleteUserSchemaResponse) {
option (google.api.http) = {
delete: "/v3alpha/user_schemas/{id}"
};
option (zitadel.protoc_gen_zitadel.v2.options) = {
auth_option: {
permission: "userschema.delete"
}
};
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
responses: {
key: "200";
value: {
description: "Schema successfully deleted";
};
};
};
}
}
message ListUserSchemasRequest {
// list limitations and ordering.
zitadel.object.v2beta.ListQuery query = 1;
// the field the result is sorted.
zitadel.user.schema.v3alpha.FieldName sorting_column = 2 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
example: "\"FIELD_NAME_TYPE\"";
}
];
// Define the criteria to query for.
repeated zitadel.user.schema.v3alpha.SearchQuery queries = 3;
}
message ListUserSchemasResponse {
// Details provides information about the returned result including total amount found.
zitadel.object.v2beta.ListDetails details = 1;
// States by which field the results are sorted.
zitadel.user.schema.v3alpha.FieldName sorting_column = 2;
// The result contains the user schemas, which matched the queries.
repeated zitadel.user.schema.v3alpha.UserSchema result = 3;
}
message GetUserSchemaByIDRequest {
// unique identifier of the schema.
string 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\"";
}
];
}
message GetUserSchemaByIDResponse {
zitadel.user.schema.v3alpha.UserSchema schema = 1;
}
message CreateUserSchemaRequest {
// Type is a human readable word describing the schema.
string type = 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: "\"employees\"";
}
];
oneof data_type {
option (validate.required) = true;
// JSON schema representation defining the user.
google.protobuf.Struct schema = 2 [
(validate.rules).message = {required: true},
(google.api.field_behavior) = REQUIRED,
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
example: "{\"$schema\":\"https://example.com/user/employees\",\"type\":\"object\",\"properties\":{\"name\":{\"type\":\"string\",\"required\":true},\"description\":{\"type\":\"string\"}}}"
}
];
// (--In the future we will allow to use an external registry.--)
}
// Defines the possible types of authenticators.
repeated AuthenticatorType possible_authenticators = 3 [
(validate.rules).repeated = {unique: true, items: {enum: {defined_only: true}}},
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
example: "[\"AUTHENTICATOR_TYPE_USERNAME\",\"AUTHENTICATOR_TYPE_PASSWORD\",\"AUTHENTICATOR_TYPE_WEBAUTHN\"]";
}
];
}
message CreateUserSchemaResponse {
// ID is the read-only unique identifier of the schema.
string id = 1;
// Details provide some base information (such as the last change date) of the schema.
zitadel.object.v2beta.Details details = 2;
}
message UpdateUserSchemaRequest {
// unique identifier of the schema.
string id = 1;
// Type is a human readable word describing the schema.
optional string type = 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: "\"employees\"";
}
];
oneof data_type {
// JSON schema representation defining the user.
google.protobuf.Struct schema = 3 [
(validate.rules).message = {required: true},
(google.api.field_behavior) = REQUIRED,
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
example: "{\"$schema\":\"https://example.com/user/employees\",\"type\":\"object\",\"properties\":{\"name\":{\"type\":\"string\",\"required\":true},\"description\":{\"type\":\"string\"}}}"
}
];
}
// Defines the possible types of authenticators.
//
// Removal of an authenticator does not remove the authenticator on a user.
repeated AuthenticatorType possible_authenticators = 4 [
(validate.rules).repeated = {unique: true, items: {enum: {defined_only: true}}},
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
example: "[\"AUTHENTICATOR_TYPE_USERNAME\",\"AUTHENTICATOR_TYPE_PASSWORD\",\"AUTHENTICATOR_TYPE_WEBAUTHN\"]";
}
];
}
message UpdateUserSchemaResponse {
// Details provide some base information (such as the last change date) of the schema.
zitadel.object.v2beta.Details details = 1;
}
message DeactivateUserSchemaRequest {
// unique identifier of the schema.
string id = 1;
}
message DeactivateUserSchemaResponse {
// Details provide some base information (such as the last change date) of the schema.
zitadel.object.v2beta.Details details = 1;
}
message ReactivateUserSchemaRequest {
// unique identifier of the schema.
string id = 1;
}
message ReactivateUserSchemaResponse {
// Details provide some base information (such as the last change date) of the schema.
zitadel.object.v2beta.Details details = 1;
}
message DeleteUserSchemaRequest {
// unique identifier of the schema.
string id = 1;
}
message DeleteUserSchemaResponse {
// Details provide some base information (such as the last change date) of the schema.
zitadel.object.v2beta.Details details = 1;
}

View File

@ -231,6 +231,32 @@ service UserService {
};
}
// Resend code to verify user email
rpc ResendEmailCode (ResendEmailCodeRequest) returns (ResendEmailCodeResponse) {
option (google.api.http) = {
post: "/v2beta/users/{user_id}/email/resend"
body: "*"
};
option (zitadel.protoc_gen_zitadel.v2.options) = {
auth_option: {
permission: "authenticated"
}
};
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
summary: "Resend code to verify user email";
description: "Resend code to verify user email."
responses: {
key: "200"
value: {
description: "OK";
}
};
};
}
// Verify the email with the provided code
rpc VerifyEmail (VerifyEmailRequest) returns (VerifyEmailResponse) {
option (google.api.http) = {
@ -281,6 +307,30 @@ service UserService {
};
}
rpc ResendPhoneCode (ResendPhoneCodeRequest) returns (ResendPhoneCodeResponse) {
option (google.api.http) = {
post: "/v2beta/users/{user_id}/phone/resend"
body: "*"
};
option (zitadel.protoc_gen_zitadel.v2.options) = {
auth_option: {
permission: "authenticated"
}
};
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
summary: "Resend code to verify user phone";
description: "Resend code to verify user phone."
responses: {
key: "200"
value: {
description: "OK";
}
};
};
}
// Verify the phone with the provided code
rpc VerifyPhone (VerifyPhoneRequest) returns (VerifyPhoneResponse) {
option (google.api.http) = {
@ -963,6 +1013,29 @@ message SetEmailResponse{
optional string verification_code = 2;
}
message ResendEmailCodeRequest{
string user_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\"";
}
];
// if no verification is specified, an email is sent with the default url
oneof verification {
SendEmailVerificationCode send_code = 2;
ReturnEmailVerificationCode return_code = 3;
}
}
message ResendEmailCodeResponse{
zitadel.object.v2beta.Details details = 1;
// in case the verification was set to return_code, the code will be returned
optional string verification_code = 2;
}
message VerifyEmailRequest{
string user_id = 1 [
(validate.rules).string = {min_len: 1, max_len: 200},
@ -1022,6 +1095,29 @@ message SetPhoneResponse{
optional string verification_code = 2;
}
message ResendPhoneCodeRequest{
string user_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\"";
}
];
// if no verification is specified, an sms is sent
oneof verification {
SendPhoneVerificationCode send_code = 3;
ReturnPhoneVerificationCode return_code = 4;
}
}
message ResendPhoneCodeResponse{
zitadel.object.v2beta.Details details = 1;
// in case the verification was set to return_code, the code will be returned
optional string verification_code = 2;
}
message VerifyPhoneRequest{
string user_id = 1 [
(validate.rules).string = {min_len: 1, max_len: 200},

View File

@ -0,0 +1,476 @@
syntax = "proto3";
package zitadel.user.v3alpha;
import "google/api/field_behavior.proto";
import "google/protobuf/struct.proto";
import "google/protobuf/timestamp.proto";
import "protoc-gen-openapiv2/options/annotations.proto";
import "validate/validate.proto";
import "zitadel/object/v2beta/object.proto";
option go_package = "github.com/zitadel/zitadel/pkg/grpc/user/v3alpha";
message Authenticators {
// All of the user's usernames, which will be used for identification during authentication.
repeated Username usernames = 1;
// If the user has set a password, the time it was last changed will be returned.
Password password = 2;
// Meta information about the user's WebAuthN authenticators.
repeated WebAuthN web_auth_n = 3;
// A list of the user's time-based one-time-password (TOTP) authenticators,
// incl. the name for identification.
repeated TOTP totps = 4;
// A list of the user's one-time-password (OTP) SMS authenticators.
repeated OTPSMS otp_sms = 5;
// A list of the user's one-time-password (OTP) Email authenticators.
repeated OTPEmail otp_email = 6;
// A list of the user's authentication keys. They can be used to authenticate e.g. by JWT Profile.
repeated AuthenticationKey authentication_keys = 7;
// A list of the user's linked identity providers (IDPs).
repeated IdentityProvider identity_providers = 8;
}
message Username {
// unique identifier of the username.
string username_id = 1;
// The user's unique username. It is used for identification during authentication.
string username = 2 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
example: "\"gigi-giraffe\"";
}
];
// By default usernames must be unique across all organizations in an instance.
// This option allow to restrict the uniqueness to the user's own organization.
// As a result, this username can only be used if the authentication is limited
// to the corresponding organization.
//
// This can be useful if you provide multiple usernames for a single user, where one
// if specific to your organization, e.g.:
// - gigi-giraffe@zitadel.com (unique across organizations)
// - gigi-giraffe (unique only inside the ZITADEL organization)
bool is_organization_specific = 3;
}
message SetUsername {
// Set the user's username. This will be used for identification during authentication.
string username = 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: "\"gigi-giraffe\"";
}
];
// By default username must be unique across all organizations in an instance.
// This option allow to restrict the uniqueness to the user's own organization.
// As a result, this username can only be used if the authentication is limited
// to the corresponding organization.
//
// This can be useful if you provide multiple usernames for a single user, where one
// if specific to your organization, e.g.:
// - gigi-giraffe@zitadel.com (unique across organizations)
// - gigi-giraffe (unique only inside the ZITADEL organization)
bool is_organization_specific = 2;
}
message Password {
// States the time the password was last changed.
google.protobuf.Timestamp last_changed = 1 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
example: "\"2019-04-01T08:45:00.000000Z\"";
}
];
}
message WebAuthN {
// unique identifier of the WebAuthN authenticator.
string web_auth_n_id = 1 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
example: "\"69629023906488334\""
}
];
// Name of the WebAuthN authenticator. This is used for easier identification.
string name = 2 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
example: "\"fido key\""
}
];
// State whether the WebAuthN registration has been completed.
bool is_verified = 3;
// States if the user has been verified during the registration. Authentication with this device
// will be considered as multi factor authentication (MFA) without the need to check a password
// (typically known as Passkeys).
// Without user verification it will be a second factor authentication (2FA), typically done
// after a password check.
//
// More on WebAuthN User Verification: https://www.w3.org/TR/webauthn/#user-verification
bool user_verified = 4;
}
message OTPSMS {
// unique identifier of the one-time-password (OTP) SMS authenticator.
string otp_sms_id = 1 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
example: "\"69629023906488334\""
}
];
// The phone number used for the OTP SMS authenticator.
string phone = 2 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
example: "\"+41791234567\"";
}
];
// State whether the OTP SMS registration has been completed.
bool is_verified = 3;
}
message OTPEmail {
// unique identifier of the one-time-password (OTP) Email authenticator.
string otp_email_id = 1 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
example: "\"69629023906488334\""
}
];
// The email address used for the OTP Email authenticator.
string address = 2 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
example: "\"mini@mouse.com\"";
}
];
// State whether the OTP Email registration has been completed.
bool is_verified = 3;
}
message TOTP {
// unique identifier of the time-based one-time-password (TOTP) authenticator.
string totp_id = 1 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
example: "\"69629023906488334\""
}
];
// The name provided during registration. This is used for easier identification.
string name = 2 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
example: "\"Google Authenticator\""
}
];
// State whether the TOTP registration has been completed.
bool is_verified = 3;
}
message AuthenticationKey {
// ID is the read-only unique identifier of the authentication key.
string authentication_key_id = 1 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
example: "\"69629023906488334\"";
}
];
zitadel.object.v2beta.Details details = 2;
// the file type of the key
AuthNKeyType type = 3 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
example: "\"KEY_TYPE_JSON\"";
}
];
// After the expiration date, the key will no longer be usable for authentication.
google.protobuf.Timestamp expiration_date = 4 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
example: "\"3019-04-01T08:45:00.000000Z\"";
}
];
}
enum AuthNKeyType {
AUTHN_KEY_TYPE_UNSPECIFIED = 0;
AUTHN_KEY_TYPE_JSON = 1;
}
message IdentityProvider {
// IDP ID is the read-only unique identifier of the identity provider in ZITADEL.
string idp_id = 1 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
example: "\"69629023906488334\"";
}
];
// IDP name is the name of the identity provider in ZITADEL.
string idp_name = 3 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
example: "\"google\"";
}
];
// The user ID represents the ID provided by the identity provider.
// This ID is used to link the user in ZITADEL with the identity provider.
string user_id = 4 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
example: "\"as-12-df-89\"";
}
];
// The username represents the username provided by the identity provider.
string username = 5 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
example: "\"gigi.long-neck@gmail.com\"";
}
];
}
message SetAuthenticators {
repeated SetUsername usernames = 1;
SetPassword password = 2;
}
message SetPassword {
oneof type {
// Provide the plain text password. ZITADEL will take care to store it in a secure way (hash).
string password = 1 [
(validate.rules).string = {min_len: 1, max_len: 200},
(google.api.field_behavior) = REQUIRED,
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
example: "\"Secr3tP4ssw0rd!\"";
min_length: 1,
max_length: 200;
}
];
// Encoded hash of a password in Modular Crypt Format:
// https://zitadel.com/docs/concepts/architecture/secrets#hashed-secrets.
string hash = 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: "\"$2a$12$lJ08fqVr8bFJilRVnDT9QeULI7YW.nT3iwUv6dyg0aCrfm3UY8XR2\"";
}
];
}
// Provide if the user needs to change the password on the next use.
bool change_required = 3;
}
message SendPasswordResetEmail {
// Optionally set a url_template, which will be used in the password reset mail
// sent by ZITADEL to guide the user to your password change page.
// If no template is set, the default ZITADEL url will be used.
optional string url_template = 2 [
(validate.rules).string = {min_len: 1, max_len: 200},
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
min_length: 1;
max_length: 200;
example: "\"https://example.com/password/changey?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}\"";
}
];
}
message SendPasswordResetSMS {}
message ReturnPasswordResetCode {}
enum WebAuthNAuthenticatorType {
WEB_AUTH_N_AUTHENTICATOR_UNSPECIFIED = 0;
WEB_AUTH_N_AUTHENTICATOR_PLATFORM = 1;
WEB_AUTH_N_AUTHENTICATOR_CROSS_PLATFORM = 2;
}
message AuthenticatorRegistrationCode {
// ID to the one time code generated by ZITADEL.
string 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: "\"e2a48d6a-362b-4db6-a1fb-34feab84dc62\"";
}
];
// one time code generated by ZITADEL.
string code = 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: "\"SKJd342k\"";
}
];
}
message SendWebAuthNRegistrationLink {
// Optionally set a url_template, which will be used in the mail sent by ZITADEL
// to guide the user to your passkey registration page.
// If no template is set, the default ZITADEL url will be used.
optional string url_template = 1 [
(validate.rules).string = {min_len: 1, max_len: 200},
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
min_length: 1;
max_length: 200;
example: "\"https://example.com/passkey/register?userID={{.UserID}}&orgID={{.OrgID}}&codeID={{.CodeID}}&code={{.Code}}\"";
}
];
}
message ReturnWebAuthNRegistrationCode {}
message RedirectURLs {
// URL to which the user will be redirected after a successful login.
string success_url = 1 [
(validate.rules).string = {min_len: 1, max_len: 200, uri_ref: true},
(google.api.field_behavior) = REQUIRED,
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
min_length: 1;
max_length: 200;
example: "\"https://custom.com/login/idp/success\"";
}
];
// URL to which the user will be redirected after a failed login.
string failure_url = 2 [
(validate.rules).string = {min_len: 1, max_len: 200, uri_ref: true},
(google.api.field_behavior) = REQUIRED,
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
min_length: 1;
max_length: 200;
example: "\"https://custom.com/login/idp/fail\"";
}
];
}
message LDAPCredentials {
// Username used to login through LDAP.
string username = 1 [
(validate.rules).string = {min_len: 1, max_len: 200, uri_ref: true},
(google.api.field_behavior) = REQUIRED,
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
min_length: 1;
max_length: 200;
example: "\"username\"";
}
];
// Password used to login through LDAP.
string password = 2 [
(validate.rules).string = {min_len: 1, max_len: 200, uri_ref: true},
(google.api.field_behavior) = REQUIRED,
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
min_length: 1;
max_length: 200;
example: "\"Password1!\"";
}
];
}
message IdentityProviderIntent {
// ID of the identity provider (IDP) intent.
string idp_intent_id = 1 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
example: "\"163840776835432705\"";
}
];
// Token of the identity provider (IDP) intent.
string idp_intent_token = 2 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
example: "\"SJKL3ioIDpo342ioqw98fjp3sdf32wahb=\"";
}
];
// If the user was already federated and linked to a ZITADEL user, it's id will be returned.
optional string user_id = 3 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
example: "\"163840776835432345\"";
}
];
}
message IDPInformation{
// ID of the identity provider.
string idp_id = 1 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
example: "\"69629026806489455\"";
}
];
// ID of the user provided by the identity provider.
string user_id = 2 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
example: "\"d654e6ba-70a3-48ef-a95d-37c8d8a7901a\"";
}
];
// Username of the user provided by the identity provider.
string user_name = 3 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
example: "\"user@external.com\"";
}
];
// Complete information returned by the identity provider.
google.protobuf.Struct raw_information = 4;
// additional access information returned by the identity provider.
oneof access{
// OAuth/OIDC access (and id_token) returned by the identity provider.
IDPOAuthAccessInformation oauth = 5;
// LDAP entity attributes returned by the identity provider
IDPLDAPAccessInformation ldap = 6;
// SAMLResponse returned by the identity provider
IDPSAMLAccessInformation saml = 7;
}
}
message IDPOAuthAccessInformation{
// The access_token returned by the identity provider.
string access_token = 1 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
example: "\"JWLKFSJlijorifjOJOIehjt8jOIEWJGh3tgiEN3WIUGH8Ehgiewhg\"";
}
];
// In case the provider returned an id_token.
optional string id_token = 2 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
example: "\"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c\"";
}
];
}
message IDPLDAPAccessInformation{
// The attributes of the user returned by the identity provider.
google.protobuf.Struct attributes = 1;
}
message IDPSAMLAccessInformation{
// The SAML assertion returned by the identity provider.
bytes assertion = 1 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
example: "\"PEFzc2VydGlvbiB4bWxucz11cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoyLjA6YXNzZXJ0aW9uIElEPV9mNjc5NDE5MjliZGY5MTcyOTMyMyBJc3N1ZUluc3RhbnQ9MjAyNC0wMi0wOFQxMzo1MTozNy45NDdaIFZlcnNpb249Mi4wPjxJc3N1ZXIgeG1sbnM9dXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6Mi4wOmFzc2VydGlvbiBOYW1lUXVhbGlmaWVyPSBTUE5hbWVRdWFsaWZpZXI9IEZvcm1hdD11cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoyLjA6bmFtZWlkLWZvcm1hdDplbnRpdHkgU1BQcm92aWRlZElEPT5odHRwczovL3NhbWwuZXhhbXBsZS5jb20vZW50aXR5aWRcPC9Jc3N1ZXJcPlw8U2lnbmF0dXJlXD5cPFNwYWNlXD5cPC9TcGFjZVw+XDxUYWdcPlw8L1RhZ1w+XDwvU2lnbmF0dXJlXD5cPFN1YmplY3QgeG1sbnM9dXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6Mi4wOmFzc2VydGlvbj48TmFtZUlEIE5hbWVRdWFsaWZpZXI9IFNQTmFtZVF1YWxpZmllcj0gRm9ybWF0PXVybjpvYXNpczpuYW1lczp0YzpTQU1MOjEuMTpuYW1laWQtZm9ybWF0OmVtYWlsQWRkcmVzcyBTUFByb3ZpZGVkSUQ9PmphY2tzb25AZXhhbXBsZS5jb208L05hbWVJRD48U3ViamVjdENvbmZpcm1hdGlvbiBNZXRob2Q9dXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6Mi4wOmNtOmJlYXJlcj48U3ViamVjdENvbmZpcm1hdGlvbkRhdGEgTm90T25PckFmdGVyPTIwMjQtMDItMDhUMTM6NTY6MzcuOTQ3WiBOb3RCZWZvcmU9MDAwMS0wMS0wMVQwMDowMDowMFogUmVjaXBpZW50PWh0dHBzOi8vZGVtby56aXRhZGVsLmNsb3VkL2lkcHMvMjUyODM0OTQ3NjU4NzA5NzYyL3NhbWwvYWNzIEluUmVzcG9uc2VUbz1pZC0yMGIxZGEyNWUzNzVhYWQyYmZmNjIxOGY2ZmUzMWRmMzYzNTRjMmI2IEFkZHJlc3M9PjwvU3ViamVjdENvbmZpcm1hdGlvbkRhdGE+PC9TdWJqZWN0Q29uZmlybWF0aW9uPjwvU3ViamVjdD48Q29uZGl0aW9ucyBOb3RCZWZvcmU9MjAyNC0wMi0wOFQxMzo0NjozNy45NDdaIE5vdE9uT3JBZnRlcj0yMDI0LTAyLTA4VDEzOjU2OjM3Ljk0N1o+PEF1ZGllbmNlUmVzdHJpY3Rpb24+PEF1ZGllbmNlPmh0dHBzOi8vZGVtby56aXRhZGVsLmNsb3VkL2lkcHMvMjUyODM0OTQ3NjU4NzA5NzYyL3NhbWwvbWV0YWRhdGFcPC9BdWRpZW5jZVw+XDwvQXVkaWVuY2VSZXN0cmljdGlvblw+XDwvQ29uZGl0aW9uc1w+XDxBdXRoblN0YXRlbWVudCBBdXRobkluc3RhbnQ9MjAyNC0wMi0wOFQxMzo1MTozNy45NDdaIFNlc3Npb25JbmRleD1pZC0yMGIxZGEyNWUzNzVhYWQyYmZmNjIxOGY2ZmUzMWRmMzYzNTRjMmI2PjxBdXRobkNvbnRleHQ+PEF1dGhuQ29udGV4dENsYXNzUmVmPnVybjpvYXNpczpuYW1lczp0YzpTQU1MOjIuMDphYzpjbGFzc2VzOlBhc3N3b3JkUHJvdGVjdGVkVHJhbnNwb3J0PC9BdXRobkNvbnRleHRDbGFzc1JlZj48L0F1dGhuQ29udGV4dD48L0F1dGhuU3RhdGVtZW50PjxBdHRyaWJ1dGVTdGF0ZW1lbnQ+PEF0dHJpYnV0ZSBGcmllbmRseU5hbWU9IE5hbWU9aWQgTmFtZUZvcm1hdD11cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoyLjA6YXR0cm5hbWUtZm9ybWF0OnVuc3BlY2lmaWVkPjxBdHRyaWJ1dGVWYWx1ZSB4bWxuczpfWE1MU2NoZW1hLWluc3RhbmNlPWh0dHA6Ly93d3cudzMub3JnLzIwMDEvWE1MU2NoZW1hLWluc3RhbmNlIF9YTUxTY2hlbWEtaW5zdGFuY2U6dHlwZT14czpzdHJpbmc+MWRkYTlmYjQ5MWRjMDFiZDI0ZDI0MjNiYTJmMjJhZTU2MWY1NmRkZjIzNzZiMjlhMTFjODAyODFkMjEyMDFmOTwvQXR0cmlidXRlVmFsdWU+PC9BdHRyaWJ1dGU+PEF0dHJpYnV0ZSBGcmllbmRseU5hbWU9IE5hbWU9ZW1haWwgTmFtZUZvcm1hdD11cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoyLjA6YXR0cm5hbWUtZm9ybWF0OnVuc3BlY2lmaWVkPjxBdHRyaWJ1dGVWYWx1ZSB4bWxuczpfWE1MU2NoZW1hLWluc3RhbmNlPWh0dHA6Ly93d3cudzMub3JnLzIwMDEvWE1MU2NoZW1hLWluc3RhbmNlIF9YTUxTY2hlbWEtaW5zdGFuY2U6dHlwZT14czpzdHJpbmc+amFja3NvbkBleGFtcGxlLmNvbTwvQXR0cmlidXRlVmFsdWU+PC9BdHRyaWJ1dGU+PEF0dHJpYnV0ZSBGcmllbmRseU5hbWU9IE5hbWU9Zmlyc3ROYW1lIE5hbWVGb3JtYXQ9dXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6Mi4wOmF0dHJuYW1lLWZvcm1hdDp1bnNwZWNpZmllZD48QXR0cmlidXRlVmFsdWUgeG1sbnM6X1hNTFNjaGVtYS1pbnN0YW5jZT1odHRwOi8vd3d3LnczLm9yZy8yMDAxL1hNTFNjaGVtYS1pbnN0YW5jZSBfWE1MU2NoZW1hLWluc3RhbmNlOnR5cGU9eHM6c3RyaW5nPmphY2tzb248L0F0dHJpYnV0ZVZhbHVlPjwvQXR0cmlidXRlPjxBdHRyaWJ1dGUgRnJpZW5kbHlOYW1lPSBOYW1lPWxhc3ROYW1lIE5hbWVGb3JtYXQ9dXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6Mi4wOmF0dHJuYW1lLWZvcm1hdDp1bnNwZWNpZmllZD48QXR0cmlidXRlVmFsdWUgeG1sbnM6X1hNTFNjaGVtYS1pbnN0YW5jZT1odHRwOi8vd3d3LnczLm9yZy8yMDAxL1hNTFNjaGVtYS1pbnN0YW5jZSBfWE1MU2NoZW1hLWluc3RhbmNlOnR5cGU9eHM6c3RyaW5nPmphY2tzb248L0F0dHJpYnV0ZVZhbHVlPjwvQXR0cmlidXRlPjwvQXR0cmlidXRlU3RhdGVtZW50PjwvQXNzZXJ0aW9uPg==\""
}
];
}
message IDPAuthenticator {
// ID of the identity provider
string idp_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\"";
}
];
// ID of the user provided by the identity provider
string user_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: "\"d654e6ba-70a3-48ef-a95d-37c8d8a7901a\"";
}
];
// Username of the user provided by the identity provider.
string user_name = 3 [
(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: "\"user@external.com\"";
}
];
}

View File

@ -0,0 +1,109 @@
syntax = "proto3";
package zitadel.user.v3alpha;
option go_package = "github.com/zitadel/zitadel/pkg/grpc/user/v3alpha";
import "google/api/field_behavior.proto";
import "protoc-gen-openapiv2/options/annotations.proto";
import "validate/validate.proto";
message Contact {
// Email contact information of the user.
Email email = 1;
// Phone contact information of the user.
Phone phone = 2;
}
message Email {
// Email address of the user.
string address = 1 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
example: "\"mini@mouse.com\"";
}
];
// IsVerified states if the email address has been verified to belong to the user.
bool is_verified = 2;
}
message Phone {
// Phone number of the user.
string number = 1 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
example: "\"+41791234567\"";
}
];
// IsVerified states if the phone number has been verified to belong to the user.
bool is_verified = 2;
}
message SetContact {
optional SetEmail email = 1;
optional SetPhone phone = 2;
}
message SetEmail {
// Set the email address.
string address = 1 [
(validate.rules).string = {min_len: 1, max_len: 200, email: true},
(google.api.field_behavior) = REQUIRED,
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
min_length: 1;
max_length: 200;
example: "\"mini@mouse.com\"";
}
];
// if no verification is specified, an email is sent with the default url
oneof verification {
// Let ZITADEL send the link to the user via email.
SendEmailVerificationCode send_code = 2;
// Get the code back to provide it to the user in your preferred mechanism.
ReturnEmailVerificationCode return_code = 3;
// Set the email as already verified.
bool is_verified = 4 [(validate.rules).bool.const = true];
}
}
message SendEmailVerificationCode {
// Optionally set a url_template, which will be used in the verification mail sent by ZITADEL
// to guide the user to your verification page.
// If no template is set, the default ZITADEL url will be used.
optional string url_template = 1 [
(validate.rules).string = {min_len: 1, max_len: 200},
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
min_length: 1;
max_length: 200;
example: "\"https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}\"";
}
];
}
message ReturnEmailVerificationCode {}
message SetPhone {
// Set the user's phone number.
string number = 1 [
(validate.rules).string = {min_len: 1, max_len: 20},
(google.api.field_behavior) = REQUIRED,
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
min_length: 1;
max_length: 20;
example: "\"+41791234567\"";
}
];
// if no verification is specified, a SMS is sent
oneof verification {
// Let ZITADEL send the link to the user via SMS.
SendPhoneVerificationCode send_code = 2;
// Get the code back to provide it to the user in your preferred mechanism.
ReturnPhoneVerificationCode return_code = 3;
// Set the phone as already verified.
bool is_verified = 4 [(validate.rules).bool.const = true];
}
}
message SendPhoneVerificationCode {}
message ReturnPhoneVerificationCode {}

View File

@ -0,0 +1,207 @@
syntax = "proto3";
package zitadel.user.v3alpha;
option go_package = "github.com/zitadel/zitadel/pkg/grpc/user/v3alpha";
import "google/api/field_behavior.proto";
import "protoc-gen-openapiv2/options/annotations.proto";
import "validate/validate.proto";
import "zitadel/user/v3alpha/user.proto";
import "zitadel/object/v2beta/object.proto";
message SearchQuery {
oneof query {
option (validate.required) = true;
// Union the results of each sub query ('OR').
OrQuery or_query = 1;
// Limit the result to match all sub queries ('AND').
// Note that if you specify multiple queries, they will be implicitly used as andQueries.
// Use the andQuery in combination with orQuery and notQuery.
AndQuery and_query = 2;
// Exclude / Negate the result of the sub query ('NOT').
NotQuery not_query = 3;
// Limit the result to a specific user ID.
UserIDQuery user_id_query = 4;
// Limit the result to a specific organization.
OrganizationIDQuery organization_id_query = 5;
// Limit the result to a specific username.
UsernameQuery username_query = 6;
// Limit the result to a specific contact email.
EmailQuery email_query = 7;
// Limit the result to a specific contact phone.
PhoneQuery phone_query = 8;
// Limit the result to a specific state of the user.
StateQuery state_query = 9;
// Limit the result to a specific schema ID.
SchemaIDQuery schema_ID_query = 10;
// Limit the result to a specific schema type.
SchemaTypeQuery schema_type_query = 11;
}
}
message OrQuery {
repeated SearchQuery queries = 1 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
example: "[{\"userIdQuery\": {\"id\": \"163840776835432705\",\"method\": \"TEXT_QUERY_METHOD_EQUALS\"}},{\"userIdQuery\": {\"id\": \"163840776835943483\",\"method\": \"TEXT_QUERY_METHOD_EQUALS\"}}]"
}
];
}
message AndQuery {
repeated SearchQuery queries = 1 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
example: "[{\"organizationIdQuery\": {\"id\": \"163840776835432705\",\"method\": \"TEXT_QUERY_METHOD_EQUALS\"}},{\"usernameQuery\": {\"username\": \"gigi\",\"method\": \"TEXT_QUERY_METHOD_EQUALS\"}}]"
}
];
}
message NotQuery {
SearchQuery query = 1 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
example: "{\"schemaIDQuery\": {\"id\": \"163840776835432705\"}}"
}
];
}
message UserIDQuery {
// Defines the ID of the user to query for.
string 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: "\"163840776835432705\"";
}
];
// Defines which text comparison method used for the id query.
zitadel.object.v2beta.TextQueryMethod method = 2 [
(validate.rules).enum.defined_only = true
];
}
message OrganizationIDQuery {
// Defines the ID of the organization to query for.
string 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: "\"163840776835432705\"";
}
];
// Defines which text comparison method used for the id query.
zitadel.object.v2beta.TextQueryMethod method = 2 [
(validate.rules).enum.defined_only = true
];
}
message UsernameQuery {
// Defines the username to query for.
string username = 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: "\"gigi-giraffe\"";
}
];
// Defines which text comparison method used for the username query.
zitadel.object.v2beta.TextQueryMethod method = 2 [
(validate.rules).enum.defined_only = true
];
// Defines that the username must only be unique in the organisation.
bool is_organization_specific = 3;
}
message EmailQuery {
// Defines the email of the user to query for.
string address = 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: "\"gigi@zitadel.com\"";
}
];
// Defines which text comparison method used for the email query.
zitadel.object.v2beta.TextQueryMethod method = 2 [
(validate.rules).enum.defined_only = true
];
}
message PhoneQuery {
// Defines the phone of the user to query for.
string number = 1 [
(validate.rules).string = {min_len: 1, max_len: 20},
(google.api.field_behavior) = REQUIRED,
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
min_length: 1;
max_length: 20;
example: "\"+41791234567\"";
}
];
// Defines which text comparison method used for the phone query.
zitadel.object.v2beta.TextQueryMethod method = 2 [
(validate.rules).enum.defined_only = true
];
}
message StateQuery {
// Defines the state to query for.
State state = 1 [
(validate.rules).enum.defined_only = true,
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
example: "\"STATE_ACTIVE\""
}
];
}
message SchemaIDQuery {
// Defines the ID of the schema to query for.
string 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: "\"163840776835432705\"";
}
];
}
message SchemaTypeQuery {
// Defines which type to query for.
string type = 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: "\"employees\"";
}
];
// Defines which text comparison method used for the type query.
zitadel.object.v2beta.TextQueryMethod method = 2 [
(validate.rules).enum.defined_only = true
];
}
enum FieldName {
FIELD_NAME_UNSPECIFIED = 0;
FIELD_NAME_ID = 1;
FIELD_NAME_CREATION_DATE = 2;
FIELD_NAME_CHANGE_DATE = 3;
FIELD_NAME_EMAIL = 4;
FIELD_NAME_PHONE = 5;
FIELD_NAME_STATE = 6;
FIELD_NAME_SCHEMA_ID = 7;
FIELD_NAME_SCHEMA_TYPE = 8;
}

View File

@ -0,0 +1,66 @@
syntax = "proto3";
package zitadel.user.v3alpha;
import "google/api/field_behavior.proto";
import "google/protobuf/struct.proto";
import "google/protobuf/timestamp.proto";
import "protoc-gen-openapiv2/options/annotations.proto";
import "validate/validate.proto";
import "zitadel/object/v2beta/object.proto";
import "zitadel/user/v3alpha/authenticator.proto";
import "zitadel/user/v3alpha/communication.proto";
option go_package = "github.com/zitadel/zitadel/pkg/grpc/user/v3alpha";
message User {
// ID is the read-only unique identifier of the user.
string user_id = 1 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
example: "\"69629012906488334\"";
}
];
// Details provide some base information (such as the last change date) of the user.
zitadel.object.v2beta.Details details = 2;
// The user's authenticators. They are used to identify and authenticate the user
// during the authentication process.
Authenticators authenticators = 3;
// Contact information for the user. ZITADEL will use this in case of internal notifications.
Contact contact = 4;
// State of the user.
State state = 5;
// The schema the user and it's data is based on.
Schema schema = 6;
// The user's data based on the provided schema.
google.protobuf.Struct data = 7;
}
enum State {
USER_STATE_UNSPECIFIED = 0;
USER_STATE_ACTIVE = 1;
USER_STATE_INACTIVE = 2;
USER_STATE_DELETED = 3;
USER_STATE_LOCKED = 4;
}
message Schema {
// The unique identifier of the user schema.
string id = 1 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
example: "\"69629026806489455\""
}
];
// The human readable name of the user schema.
string type = 2 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
example: "\"employees\"";
}
];
// The revision the user's data is based on of the revision.
uint32 revision = 3 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
example: "7";
}
];
}

File diff suppressed because it is too large Load Diff