Merge branch 'master' into otp-rework
This commit is contained in:
commit
285a69d91f
|
@ -52,6 +52,14 @@ jobs:
|
|||
run: sudo pip install -U wheel pipenv && pipenv install --dev
|
||||
- name: Lint with bandit
|
||||
run: pipenv run bandit -r passbook
|
||||
snyk:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@master
|
||||
- name: Run Snyk to check for vulnerabilities
|
||||
uses: snyk/actions/python@master
|
||||
env:
|
||||
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
|
||||
pyright:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
|
|
@ -46,18 +46,18 @@
|
|||
},
|
||||
"boto3": {
|
||||
"hashes": [
|
||||
"sha256:2616351c98eec18d20a1d64b33355c86cd855ac96219d1b8428c9bfc590bde53",
|
||||
"sha256:7daad26a008c91dd7b82fde17d246d1fe6e4b3813426689ef8bac9017a277cfb"
|
||||
"sha256:77d926c16ab2ab2bfe68811f3bc987e7d79c97f3e2adebf9265c587cdb1fc47b",
|
||||
"sha256:7f558165eaa608a5d0e05227ee820f4b3cc74533a52c9dde7eb488eba091d50d"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==1.14.12"
|
||||
"version": "==1.14.13"
|
||||
},
|
||||
"botocore": {
|
||||
"hashes": [
|
||||
"sha256:45934d880378777cefeca727f369d1f5aebf6b254e9be58e7c77dd0b059338bb",
|
||||
"sha256:a94e0e2307f1b9fe3a84660842909cd2680b57a9fc9fb0c3a03b0afb2eadbe21"
|
||||
"sha256:37b65cc48c99b7dd4d5606e56f76ecd88eb7be392ea8a166df734a6b3035301c",
|
||||
"sha256:5ac9b53a75852fe4282be8741c8136e6948714fef6eff1bd9babb861f8647ba3"
|
||||
],
|
||||
"version": "==1.17.12"
|
||||
"version": "==1.17.13"
|
||||
},
|
||||
"celery": {
|
||||
"hashes": [
|
||||
|
|
|
@ -61,6 +61,7 @@
|
|||
<td>
|
||||
<a class="pf-c-button pf-m-secondary" href="{% url 'passbook_admin:flow-update' pk=flow.pk %}?back={{ request.get_full_path }}">{% trans 'Edit' %}</a>
|
||||
<a class="pf-c-button pf-m-danger" href="{% url 'passbook_admin:flow-delete' pk=flow.pk %}?back={{ request.get_full_path }}">{% trans 'Delete' %}</a>
|
||||
<a class="pf-c-button pf-m-secondary" href="{% url 'passbook_admin:flow-execute' pk=flow.pk %}?next={{ request.get_full_path }}">{% trans 'Execute' %}</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
|
|
@ -120,7 +120,17 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="pf-c-card__body">
|
||||
<i class="pf-icon pf-icon-ok"></i> {{ version }}
|
||||
{% if version >= version_latest %}
|
||||
<i class="pf-icon pf-icon-ok"></i>
|
||||
{% blocktrans with version=version %}
|
||||
{{ version }} (Up-to-date!)
|
||||
{% endblocktrans %}
|
||||
{% else %}
|
||||
<i class="pf-icon pf-icon-warning-triangle"></i>
|
||||
{% blocktrans with version=version latest=version_latest %}
|
||||
{{ version }} ({{ latest }} is available!)
|
||||
{% endblocktrans %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -3,7 +3,6 @@ from django.urls import path
|
|||
|
||||
from passbook.admin.views import (
|
||||
applications,
|
||||
audit,
|
||||
certificate_key_pair,
|
||||
debug,
|
||||
flows,
|
||||
|
@ -188,6 +187,11 @@ urlpatterns = [
|
|||
path(
|
||||
"flows/<uuid:pk>/update/", flows.FlowUpdateView.as_view(), name="flow-update",
|
||||
),
|
||||
path(
|
||||
"flows/<uuid:pk>/execute/",
|
||||
flows.FlowDebugExecuteView.as_view(),
|
||||
name="flow-execute",
|
||||
),
|
||||
path(
|
||||
"flows/<uuid:pk>/delete/", flows.FlowDeleteView.as_view(), name="flow-delete",
|
||||
),
|
||||
|
@ -252,8 +256,6 @@ urlpatterns = [
|
|||
certificate_key_pair.CertificateKeyPairDeleteView.as_view(),
|
||||
name="certificatekeypair-delete",
|
||||
),
|
||||
# Audit Log
|
||||
path("audit/", audit.EventListView.as_view(), name="audit-log"),
|
||||
# Groups
|
||||
path("groups/", groups.GroupListView.as_view(), name="groups"),
|
||||
# Debug
|
||||
|
|
|
@ -5,13 +5,17 @@ from django.contrib.auth.mixins import (
|
|||
PermissionRequiredMixin as DjangoPermissionRequiredMixin,
|
||||
)
|
||||
from django.contrib.messages.views import SuccessMessageMixin
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.urls import reverse_lazy
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.views.generic import DeleteView, ListView, UpdateView
|
||||
from django.views.generic import DeleteView, DetailView, ListView, UpdateView
|
||||
from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
|
||||
|
||||
from passbook.flows.forms import FlowForm
|
||||
from passbook.flows.models import Flow
|
||||
from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER
|
||||
from passbook.flows.views import SESSION_KEY_PLAN, FlowPlanner
|
||||
from passbook.lib.utils.urls import redirect_with_qs
|
||||
from passbook.lib.views import CreateAssignPermView
|
||||
|
||||
|
||||
|
@ -46,6 +50,25 @@ class FlowCreateView(
|
|||
return super().get_context_data(**kwargs)
|
||||
|
||||
|
||||
class FlowDebugExecuteView(LoginRequiredMixin, PermissionRequiredMixin, DetailView):
|
||||
"""Debug exectue flow, setting the current user as pending user"""
|
||||
|
||||
model = Flow
|
||||
permission_required = "passbook_flows.view_flow"
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def get(self, request: HttpRequest, pk: str) -> HttpResponse:
|
||||
"""Debug exectue flow, setting the current user as pending user"""
|
||||
flow: Flow = self.get_object()
|
||||
planner = FlowPlanner(flow)
|
||||
planner.use_cache = False
|
||||
plan = planner.plan(self.request, {PLAN_CONTEXT_PENDING_USER: request.user,},)
|
||||
self.request.session[SESSION_KEY_PLAN] = plan
|
||||
return redirect_with_qs(
|
||||
"passbook_flows:flow-executor-shell", self.request.GET, flow_slug=flow.slug,
|
||||
)
|
||||
|
||||
|
||||
class FlowUpdateView(
|
||||
SuccessMessageMixin, LoginRequiredMixin, PermissionRequiredMixin, UpdateView
|
||||
):
|
||||
|
|
|
@ -1,7 +1,11 @@
|
|||
"""passbook administration overview"""
|
||||
from functools import lru_cache
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.shortcuts import redirect, reverse
|
||||
from django.views.generic import TemplateView
|
||||
from packaging.version import Version, parse
|
||||
from requests import RequestException, get
|
||||
|
||||
from passbook import __version__
|
||||
from passbook.admin.mixins import AdminRequiredMixin
|
||||
|
@ -12,6 +16,19 @@ from passbook.root.celery import CELERY_APP
|
|||
from passbook.stages.invitation.models import Invitation
|
||||
|
||||
|
||||
@lru_cache
|
||||
def latest_version() -> Version:
|
||||
"""Get latest release from GitHub, cached"""
|
||||
try:
|
||||
data = get(
|
||||
"https://api.github.com/repos/beryju/passbook/releases/latest"
|
||||
).json()
|
||||
tag_name = data.get("tag_name")
|
||||
return parse(tag_name.split("/")[1])
|
||||
except RequestException:
|
||||
return parse("0.0.0")
|
||||
|
||||
|
||||
class AdministrationOverviewView(AdminRequiredMixin, TemplateView):
|
||||
"""Overview View"""
|
||||
|
||||
|
@ -33,7 +50,8 @@ class AdministrationOverviewView(AdminRequiredMixin, TemplateView):
|
|||
kwargs["stage_count"] = len(Stage.objects.all())
|
||||
kwargs["flow_count"] = len(Flow.objects.all())
|
||||
kwargs["invitation_count"] = len(Invitation.objects.all())
|
||||
kwargs["version"] = __version__
|
||||
kwargs["version"] = parse(__version__)
|
||||
kwargs["version_latest"] = latest_version()
|
||||
kwargs["worker_count"] = len(CELERY_APP.control.ping(timeout=0.5))
|
||||
kwargs["providers_without_application"] = Provider.objects.filter(
|
||||
application=None
|
||||
|
|
|
@ -32,6 +32,7 @@ from passbook.sources.ldap.api import LDAPPropertyMappingViewSet, LDAPSourceView
|
|||
from passbook.sources.oauth.api import OAuthSourceViewSet
|
||||
from passbook.sources.saml.api import SAMLSourceViewSet
|
||||
from passbook.stages.captcha.api import CaptchaStageViewSet
|
||||
from passbook.stages.consent.api import ConsentStageViewSet
|
||||
from passbook.stages.dummy.api import DummyStageViewSet
|
||||
from passbook.stages.email.api import EmailStageViewSet
|
||||
from passbook.stages.identification.api import IdentificationStageViewSet
|
||||
|
@ -85,6 +86,7 @@ router.register("propertymappings/saml", SAMLPropertyMappingViewSet)
|
|||
|
||||
router.register("stages/all", StageViewSet)
|
||||
router.register("stages/captcha", CaptchaStageViewSet)
|
||||
router.register("stages/consent", ConsentStageViewSet)
|
||||
router.register("stages/email", EmailStageViewSet)
|
||||
router.register("stages/identification", IdentificationStageViewSet)
|
||||
router.register("stages/invitation", InvitationStageViewSet)
|
||||
|
|
|
@ -1,2 +1,9 @@
|
|||
"""passbook audit urls"""
|
||||
urlpatterns = []
|
||||
from django.urls import path
|
||||
|
||||
from passbook.audit.views import EventListView
|
||||
|
||||
urlpatterns = [
|
||||
# Audit Log
|
||||
path("audit/", EventListView.as_view(), name="log"),
|
||||
]
|
||||
|
|
|
@ -9,7 +9,7 @@ class EventListView(PermissionListMixin, ListView):
|
|||
"""Show list of all invitations"""
|
||||
|
||||
model = Event
|
||||
template_name = "administration/audit/list.html"
|
||||
template_name = "audit/list.html"
|
||||
permission_required = "passbook_audit.view_event"
|
||||
ordering = "-created"
|
||||
paginate_by = 20
|
|
@ -32,8 +32,8 @@
|
|||
{% if user.is_superuser %}
|
||||
<li class="pf-c-nav__item"><a class="pf-c-nav__link {% is_active_app 'passbook_admin' %}"
|
||||
href="{% url 'passbook_admin:overview' %}">{% trans 'Administrate' %}</a></li>
|
||||
<li class="pf-c-nav__item"><a class="pf-c-nav__link {% is_active_url 'passbook_admin:audit-log' %}"
|
||||
href="{% url 'passbook_admin:audit-log' %}">{% trans 'Monitor' %}</a></li>
|
||||
<li class="pf-c-nav__item"><a class="pf-c-nav__link {% is_active_url 'passbook_audit:log' %}"
|
||||
href="{% url 'passbook_audit:log' %}">{% trans 'Monitor' %}</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
|
|
|
@ -14,7 +14,7 @@
|
|||
{{ user.username }}
|
||||
</div>
|
||||
<div class="right">
|
||||
<a href="{% url 'passbook_flows:default-authentication' %}">{% trans 'Not you?' %}</a>
|
||||
<a href="{% url 'passbook_flows:cancel' %}">{% trans 'Not you?' %}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
{% load i18n %}
|
||||
|
||||
<style>
|
||||
.pb-exception {
|
||||
font-family: monospace;
|
||||
overflow-x: scroll;
|
||||
}
|
||||
</style>
|
||||
|
||||
<header class="pf-c-login__main-header">
|
||||
<h1 class="pf-c-title pf-m-3xl">
|
||||
{% trans 'Whoops!' %}
|
||||
</h1>
|
||||
</header>
|
||||
<div class="pf-c-login__main-body">
|
||||
<h3>
|
||||
{% trans 'Something went wrong! Please try again later.' %}
|
||||
</h3>
|
||||
{% if debug %}
|
||||
<pre class="pb-exception">{{ tb }}</pre>
|
||||
{% endif %}
|
||||
</div>
|
|
@ -3,6 +3,7 @@ from django.urls import path
|
|||
|
||||
from passbook.flows.models import FlowDesignation
|
||||
from passbook.flows.views import (
|
||||
CancelView,
|
||||
FlowExecutorShellView,
|
||||
FlowExecutorView,
|
||||
FlowPermissionDeniedView,
|
||||
|
@ -36,6 +37,7 @@ urlpatterns = [
|
|||
ToDefaultFlow.as_view(designation=FlowDesignation.UNRENOLLMENT),
|
||||
name="default-unenrollment",
|
||||
),
|
||||
path("-/cancel/", CancelView.as_view(), name="cancel"),
|
||||
path("b/<slug:flow_slug>/", FlowExecutorView.as_view(), name="flow-executor"),
|
||||
path(
|
||||
"<slug:flow_slug>/", FlowExecutorShellView.as_view(), name="flow-executor-shell"
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
"""passbook multi-stage authentication engine"""
|
||||
from traceback import format_tb
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from django.http import (
|
||||
|
@ -8,7 +9,7 @@ from django.http import (
|
|||
HttpResponseRedirect,
|
||||
JsonResponse,
|
||||
)
|
||||
from django.shortcuts import get_object_or_404, redirect, reverse
|
||||
from django.shortcuts import get_object_or_404, redirect, render, reverse
|
||||
from django.template.response import TemplateResponse
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.clickjacking import xframe_options_sameorigin
|
||||
|
@ -106,8 +107,18 @@ class FlowExecutorView(View):
|
|||
stage=self.current_stage,
|
||||
flow_slug=self.flow.slug,
|
||||
)
|
||||
stage_response = self.current_stage_view.get(request, *args, **kwargs)
|
||||
return to_stage_response(request, stage_response)
|
||||
try:
|
||||
stage_response = self.current_stage_view.get(request, *args, **kwargs)
|
||||
return to_stage_response(request, stage_response)
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
return to_stage_response(
|
||||
request,
|
||||
render(
|
||||
request,
|
||||
"flows/error.html",
|
||||
{"error": exc, "tb": "".join(format_tb(exc.__traceback__)),},
|
||||
),
|
||||
)
|
||||
|
||||
def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||
"""pass post request to current stage"""
|
||||
|
@ -117,8 +128,18 @@ class FlowExecutorView(View):
|
|||
stage=self.current_stage,
|
||||
flow_slug=self.flow.slug,
|
||||
)
|
||||
stage_response = self.current_stage_view.post(request, *args, **kwargs)
|
||||
return to_stage_response(request, stage_response)
|
||||
try:
|
||||
stage_response = self.current_stage_view.post(request, *args, **kwargs)
|
||||
return to_stage_response(request, stage_response)
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
return to_stage_response(
|
||||
request,
|
||||
render(
|
||||
request,
|
||||
"flows/error.html",
|
||||
{"error": exc, "tb": "".join(format_tb(exc.__traceback__)),},
|
||||
),
|
||||
)
|
||||
|
||||
def _initiate_plan(self) -> FlowPlan:
|
||||
planner = FlowPlanner(self.flow)
|
||||
|
@ -183,6 +204,30 @@ class FlowPermissionDeniedView(PermissionDeniedView):
|
|||
"""User could not be authenticated"""
|
||||
|
||||
|
||||
class FlowExecutorShellView(TemplateView):
|
||||
"""Executor Shell view, loads a dummy card with a spinner
|
||||
that loads the next stage in the background."""
|
||||
|
||||
template_name = "flows/shell.html"
|
||||
|
||||
def get_context_data(self, **kwargs) -> Dict[str, Any]:
|
||||
kwargs["exec_url"] = reverse("passbook_flows:flow-executor", kwargs=self.kwargs)
|
||||
kwargs["msg_url"] = reverse("passbook_api:messages-list")
|
||||
self.request.session[SESSION_KEY_GET] = self.request.GET
|
||||
return kwargs
|
||||
|
||||
|
||||
class CancelView(View):
|
||||
"""View which canels the currently active plan"""
|
||||
|
||||
def get(self, request: HttpRequest) -> HttpResponse:
|
||||
"""View which canels the currently active plan"""
|
||||
if SESSION_KEY_PLAN in request.session:
|
||||
del request.session[SESSION_KEY_PLAN]
|
||||
LOGGER.debug("Canceled current plan")
|
||||
return redirect("passbook_core:overview")
|
||||
|
||||
|
||||
class ToDefaultFlow(View):
|
||||
"""Redirect to default flow matching by designation"""
|
||||
|
||||
|
@ -206,19 +251,6 @@ class ToDefaultFlow(View):
|
|||
)
|
||||
|
||||
|
||||
class FlowExecutorShellView(TemplateView):
|
||||
"""Executor Shell view, loads a dummy card with a spinner
|
||||
that loads the next stage in the background."""
|
||||
|
||||
template_name = "flows/shell.html"
|
||||
|
||||
def get_context_data(self, **kwargs) -> Dict[str, Any]:
|
||||
kwargs["exec_url"] = reverse("passbook_flows:flow-executor", kwargs=self.kwargs)
|
||||
kwargs["msg_url"] = reverse("passbook_api:messages-list")
|
||||
self.request.session[SESSION_KEY_GET] = self.request.GET
|
||||
return kwargs
|
||||
|
||||
|
||||
def to_stage_response(request: HttpRequest, source: HttpResponse) -> HttpResponse:
|
||||
"""Convert normal HttpResponse into JSON Response"""
|
||||
if isinstance(source, HttpResponseRedirect) or source.status_code == 302:
|
||||
|
|
|
@ -38,7 +38,7 @@
|
|||
{% blocktrans with user=user %}
|
||||
You are logged in as {{ user }}. Not you?
|
||||
{% endblocktrans %}
|
||||
<a href="{% url 'passbook_flows:default-invalidation' %}">{% trans 'Logout' %}</a>
|
||||
<a href="{% url 'passbook_flows:cancel' %}">{% trans 'Logout' %}</a>
|
||||
</p>
|
||||
</div>
|
||||
<div class="pf-c-form__group pf-m-action">
|
||||
|
|
|
@ -21,7 +21,7 @@
|
|||
{{ user.username }}
|
||||
</div>
|
||||
<div class="right">
|
||||
<a href="{% url 'passbook_flows:default-authentication' %}">{% trans 'Not you?' %}</a>
|
||||
<a href="{% url 'passbook_flows:cancel' %}">{% trans 'Not you?' %}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
141
swagger.yaml
141
swagger.yaml
|
@ -3275,6 +3275,133 @@ paths:
|
|||
required: true
|
||||
type: string
|
||||
format: uuid
|
||||
/stages/consent/:
|
||||
get:
|
||||
operationId: stages_consent_list
|
||||
description: ConsentStage Viewset
|
||||
parameters:
|
||||
- name: ordering
|
||||
in: query
|
||||
description: Which field to use when ordering the results.
|
||||
required: false
|
||||
type: string
|
||||
- name: search
|
||||
in: query
|
||||
description: A search term.
|
||||
required: false
|
||||
type: string
|
||||
- name: limit
|
||||
in: query
|
||||
description: Number of results to return per page.
|
||||
required: false
|
||||
type: integer
|
||||
- name: offset
|
||||
in: query
|
||||
description: The initial index from which to return the results.
|
||||
required: false
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: ''
|
||||
schema:
|
||||
required:
|
||||
- count
|
||||
- results
|
||||
type: object
|
||||
properties:
|
||||
count:
|
||||
type: integer
|
||||
next:
|
||||
type: string
|
||||
format: uri
|
||||
x-nullable: true
|
||||
previous:
|
||||
type: string
|
||||
format: uri
|
||||
x-nullable: true
|
||||
results:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/definitions/ConsentStage'
|
||||
tags:
|
||||
- stages
|
||||
post:
|
||||
operationId: stages_consent_create
|
||||
description: ConsentStage Viewset
|
||||
parameters:
|
||||
- name: data
|
||||
in: body
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/ConsentStage'
|
||||
responses:
|
||||
'201':
|
||||
description: ''
|
||||
schema:
|
||||
$ref: '#/definitions/ConsentStage'
|
||||
tags:
|
||||
- stages
|
||||
parameters: []
|
||||
/stages/consent/{stage_uuid}/:
|
||||
get:
|
||||
operationId: stages_consent_read
|
||||
description: ConsentStage Viewset
|
||||
parameters: []
|
||||
responses:
|
||||
'200':
|
||||
description: ''
|
||||
schema:
|
||||
$ref: '#/definitions/ConsentStage'
|
||||
tags:
|
||||
- stages
|
||||
put:
|
||||
operationId: stages_consent_update
|
||||
description: ConsentStage Viewset
|
||||
parameters:
|
||||
- name: data
|
||||
in: body
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/ConsentStage'
|
||||
responses:
|
||||
'200':
|
||||
description: ''
|
||||
schema:
|
||||
$ref: '#/definitions/ConsentStage'
|
||||
tags:
|
||||
- stages
|
||||
patch:
|
||||
operationId: stages_consent_partial_update
|
||||
description: ConsentStage Viewset
|
||||
parameters:
|
||||
- name: data
|
||||
in: body
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/ConsentStage'
|
||||
responses:
|
||||
'200':
|
||||
description: ''
|
||||
schema:
|
||||
$ref: '#/definitions/ConsentStage'
|
||||
tags:
|
||||
- stages
|
||||
delete:
|
||||
operationId: stages_consent_delete
|
||||
description: ConsentStage Viewset
|
||||
parameters: []
|
||||
responses:
|
||||
'204':
|
||||
description: ''
|
||||
tags:
|
||||
- stages
|
||||
parameters:
|
||||
- name: stage_uuid
|
||||
in: path
|
||||
description: A UUID string identifying this Consent Stage.
|
||||
required: true
|
||||
type: string
|
||||
format: uuid
|
||||
/stages/dummy/:
|
||||
get:
|
||||
operationId: stages_dummy_list
|
||||
|
@ -6052,6 +6179,20 @@ definitions:
|
|||
description: Private key, acquired from https://www.google.com/recaptcha/intro/v3.html
|
||||
type: string
|
||||
minLength: 1
|
||||
ConsentStage:
|
||||
required:
|
||||
- name
|
||||
type: object
|
||||
properties:
|
||||
pk:
|
||||
title: Stage uuid
|
||||
type: string
|
||||
format: uuid
|
||||
readOnly: true
|
||||
name:
|
||||
title: Name
|
||||
type: string
|
||||
minLength: 1
|
||||
DummyStage:
|
||||
required:
|
||||
- name
|
||||
|
|
Reference in New Issue