Self-Service Onboarding (Architecture)¶
Self-service onboarding allows prospective customers to set up a DiKAS cloud instance themselves, without contacting sales — either as a free trial or directly as a paying live instance. This page describes the technical architecture, the configuration, and the security mechanisms. The end-user perspective is described under Online Registration.
Disabled by default (dark rollout)
The public funnel is protected by the SelfServiceSignupEnabled flag and is off by
default in the shipped state. As long as the flag is false, all public endpoints return
404. Before activation, the points under Before Activation must be
fulfilled.
Overview¶
The flow consists of a public frontend wizard and a series of anonymous backend endpoints. The
actual provisioning runs through the same ILicenseProvisioningService that the portal and trial
paths also use.
| Area | Location |
|---|---|
| Frontend wizard | Route /signup (PublicOnboardingWizardComponent, app dikas-web) |
| Magic-link confirmation | Route /signup/verify |
| Backend endpoints | /api/v1/onboarding/public/* and /api/v1/trial/start |
| Feature pack | Dikas.Features.Licensing |
The wizard guides the user through seven steps: Package → TSE (Cloud vs. Swissbit) → Account (email, passwordless via magic link) → Company data → Online name (subdomain) → Legal & payment → Done (instance URL and login credentials).
Feature Flag SelfServiceSignupEnabled¶
The flag is a property on the singleton document OperationalConfig (fixed ID
operationalconfig) and can only be changed via the database or the admin interface — there
is no environment variable for it.
Every public endpoint first calls CheckSelfServiceEnabledAsync() and responds with
404 Not Found as long as the flag is false. This keeps the funnel invisible until it is
deliberately switched on.
Public Endpoints¶
All endpoints are [AllowAnonymous] and are protected by the flag as well as the anti-abuse
measures.
| Method & Path | Purpose |
|---|---|
POST /api/v1/onboarding/public/orders |
Create an anonymous draft order (returns a secret order token) |
PUT /api/v1/onboarding/public/orders/{id} |
Update the draft (token-bound) |
POST /api/v1/onboarding/public/orders/{id}/reserve-name |
Reserve the online name for a short time |
POST /api/v1/onboarding/public/register |
Create a passwordless customer, send a magic link |
POST /api/v1/onboarding/public/orders/{id}/bind |
Bind the order to the account verified via the magic link ([Authorize]) |
POST /api/v1/onboarding/public/payment/set |
Create a Stripe SetupIntent/PaymentIntent (returns ClientSecret) |
POST /api/v1/onboarding/public/payment/webhook |
Stripe webhook (payment confirmation) |
POST /api/v1/onboarding/public/orders/{id}/submit |
Provision the instance |
GET /api/v1/onboarding/public/payment-config |
Stripe publishable key for the frontend |
POST /api/v1/trial/start |
Direct self-service trial (without an order/portal account) |
Security / Anti-Abuse¶
Since every provisioning run generates real costs (COGS), several independent protection layers are active:
- Honeypot — a hidden form field; if it is filled in, the request is silently rejected with
202 Accepted(no provisioning). - Error limiter per IP — exponential delay, lockout after 10 errors (1 h) or 20 errors (24 h).
- Provisioning quota per IP and 24 h (
MaxPublicProvisionsPerIpPer24h, default 3). It applies both to the public submit and to the trial start, and is not reset on success. If the quota is exceeded, the endpoint responds with429(code: SIGNUP_RATE_LIMITED). - Email-sending quota per IP and hour (
MaxPublicEmailSendsPerIpPerHour, default 5) against magic-link bombing. - Magic link instead of classic email confirmation; the click verifies the address and returns a portal JWT, which is used to bind the order.
- Real client IP — behind the ingress, the source IP is determined via
ForwardedHeaders. The trusted networks (KnownNetworks) must be set to the cluster CIDR in the deployment, otherwise the IP quota collapses.
In-memory quotas
The quotas are held in memory (a rolling time window). With multiple instances behind a load balancer, each instance counts on its own; in that case, a sticky session or a shared cache should be provided.
Configuration¶
The options are loaded from the Onboarding section (OnboardingOptions). All values have
sensible defaults in the code; they can be overridden via environment variables using a double
underscore.
| Key | Default | Meaning |
|---|---|---|
Onboarding__MaxPublicProvisionsPerIpPer24h |
3 |
Max. provisionings per IP / 24 h (submit + trial) |
Onboarding__MaxPublicEmailSendsPerIpPerHour |
5 |
Max. magic-link emails per IP / hour |
Onboarding__RequireEmailVerifyBeforeProvision |
true (Portal) |
Require email confirmation before provisioning |
Onboarding__RequireOnlinePaymentForNonTrial |
false (Portal) |
Require a real online payment for non-trial |
Payment¶
Payment runs via Stripe on a central DiKAS account (not per tenant).
| Key | Meaning |
|---|---|
Signup__Stripe__SecretKey |
Stripe secret key (activates the real payment adapter) |
Signup__Stripe__PublishableKey |
Publishable key for the frontend |
Signup__Stripe__WebhookSecret |
Signing secret for the webhook |
Without a SecretKey set, a null adapter is active (dev/test). The OnboardingPaymentMethod
values: SepaLastschrift = 1 (SEPA direct debit), StripeSetupIntent = 2 (trial-first, store a
payment method), StripePaymentIntent = 3 (pay-first, immediate payment).
Lifecycle & Cleanup¶
Two background services keep the funnel clean:
Abandoned Orders¶
OnboardingCleanupService (daily) removes abandoned/unpaid orders that have been inactive for
more than 14 days (CleanupAbandonedOnboardingsCommand, configurable) — including orphaned
anonymous public drafts (without a bound customer) — and releases their online name
reservation again. Provisioned/paid orders remain untouched.
Expired Trials (Dry-Run, Dark)¶
TrialDeprovisioningService is a service deliberately built to be conservative:
- It finds expired trials whose grace period (
GraceDays, default 30) has elapsed after the trial end (IsTrial = trueandEndDatebeyond the grace period; converted/paid licenses withIsTrial = falseare never captured). - In DRY-RUN, it only logs which tenant databases (
{DatabasePrefix}_maindb,{DatabasePrefix}_gastrocurrent) a later live run would delete — it deletes nothing. - It is fully disabled by default and only runs when
TrialDeprovision__Enabled=true.
| Key | Default | Meaning |
|---|---|---|
TrialDeprovision__Enabled |
false |
Master switch; false = the service does not run at all |
TrialDeprovision__GraceDays |
30 |
Grace period in days after the trial ends |
Real DB drop is not implemented
The actual deletion of tenant databases is deliberately not wired up. It requires explicit approval as well as a verified database name mapping, and should be coordinated with the expiry enforcement (trial end → lockout → export).
Audit¶
Every provisioning run is logged via IAuditLogger and is visible in the operator audit view
(GET /api/v1/audit/logs/{date}, role Admin only):
| Event | Trigger |
|---|---|
TRIAL_PROVISIONED |
Successful trial start (with online name, license ID, term) |
TRIAL_CONVERTED_PAID |
Conversion of a trial to a paid license |
The timestamp and client IP come from the request; for anonymous trials, the user is shown as
-. Login credentials are not written to the audit log.
Before Activation¶
Before SelfServiceSignupEnabled is ever set to true:
-
ForwardedHeaders:KnownNetworksset to the real cluster CIDR in the prod deployment (otherwise the IP quota is ineffective). - Stripe keys (
Signup__Stripe__*) set — otherwise an "immediately live" payment with a real provider would be free. - Magic-link sending and
/signup/verifyfully wired up end to end. - Flag activated in
OperationalConfig.