feat(auth): operator switches to close sign-up, tenant creation and admin e-mail password reset

Merged
EV
15d ago
+2228-80

Summary

Operators can now close public sign-up, stop new tenants (workspaces) from being created, and stop Admin and Super Admin accounts from resetting their password by e-mail. Each one is an environment switch that defaults to true, so a server that sets nothing behaves exactly as before. Invitations, users an admin creates, and the first tenant of an empty database keep working with every switch set to false.

VariableDefaultWhat false does
APP_USER_REGISTRATIONtrueCloses public sign-up: POST /api/auth/register without an admin token answers 403, and the web app hides its Register links and redirects /auth/register to sign-in.
ALLOW_TENANTS_REGISTRATIONtrueStops new tenants from being created (POST /api/tenant and the GraphQL createTenant answer 403). It also closes public sign-up, because a self-registered account can only go on to create a tenant.
ADMIN_PASSWORD_RESETtrueAdmin and Super Admin accounts get no reset e-mail, and reset links already sent to them stop working. "Forgot password" answers exactly as for any other address, so it does not reveal which addresses are admins. Every other user keeps the e-mail reset.

With registration closed, an administrator's token can still create an account; ALLOW_TENANTS_REGISTRATION=false is what stops that account from creating its own tenant.

Why

  • #5784 asks how to turn registration off. There was no setting for it: POST /auth/register is public.
  • #7821 reports a production server where anyone could sign up and create their own organization. The owner proposed ALLOW_TENANTS_REGISTRATION and ADMIN_PASSWORD_RESET there (both default true). Neither existed: ALLOW_TENANTS_REGISTRATION was not referenced anywhere, and ADMIN_PASSWORD_RESET was set by many deploy configs but read by nothing.

What changed

@gauzy/config — one place for the switches

  • auth-switches.helper.ts: IAuthSwitches, the variable names (AUTH_SWITCH_ENV), parseOnByDefaultSwitch (unset or blank → on; false/0/no/off → off; true/1/yes/on → on; anything else → on plus a startup warning, so a typo never closes sign-up by accident), resolveAuthSwitches(env) and isSelfServiceSignupAllowed(switches).
  • environment.authSwitches in environment.ts / environment.prod.ts, typed in IEnvironment.

@gauzy/contracts

  • IRegistrationConfig { enabled: boolean }, the shape of GET /api/auth/registration/config.

@gauzy/core

  • auth/auth-switches/ (exported from @gauzy/core for plugins that create accounts or tenants of their own):
    • RegistrationPolicyService: answers "is public sign-up open?" and "may a new tenant be created?". When a switch is off it still answers yes while the database has no tenant at all (first-install bootstrap). The tenant check is one EXISTS query (soft-deleted rows included) and is cached once a tenant has been seen. Works on TypeORM and MikroORM.
    • SelfRegistrationGuard on POST /auth/register, after RegisterAuthorizationGuard. While sign-up is closed, an anonymous request gets 403 with a clear message. An administrator still creates users: a body with privileged fields is already authenticated by RegisterAuthorizationGuard; for a body without them, the guard asks RegisterAuthorizationGuard.isAdministratorRequest(), which verifies the token with the same code as a privileged registration (now extracted into authenticateAdministrator: access tokens only, no third-party app tokens, account status and role from the database). A missing, invalid, expired or non-administrator token is refused, and nothing is attached to the request. With sign-up open, no token is read, as before. AuthModule also provides RegisterAuthorizationGuard so it can be injected.
    • canResetPasswordByEmail(user): false only for Admin and Super Admin roles while ADMIN_PASSWORD_RESET=false.
  • AuthController: new public GET /auth/registration/config → { enabled }, documented in Swagger; 403 documented on POST /auth/register.
  • AuthService.requestResetPassword sends no link to Admin/Super Admin accounts while the switch is off and returns the same answer as for any other address; the log line counts withheld accounts without the address. AuthService.resetPassword refuses such an account's token before consuming it, with the same generic "Password Reset Failed." the endpoint already returns.
  • TenantService.createOrResumeTenant (behind both REST POST /tenant and GraphQL createTenant) checks the policy only when it would create a new tenant; resuming the caller's own unfinished onboarding is not blocked. Every onboarding keeps holding the creator's own lock; while the first-tenant exception is open (switch off, no tenant yet) it also holds one lock shared by every creator, taken second on the same lock connection (the existing Postgres advisory lock / MySQL GET_LOCK / in-process guard). So two people onboarding at the same moment cannot both create a "first" tenant (the second gets the existing 409 and, on retry, the 403), and a creator's retry can never run alongside their own unfinished first onboarding. AuthModule registers the tenant repositories the policy reads.

@gauzy/plugin-auth-zitadel

  • The Ever ID sign-up path (ZITADEL_SIGNUP_ENABLED, Ever Cloud only) stays closed while APP_USER_REGISTRATION or ALLOW_TENANTS_REGISTRATION is false, with a startup warning.

@gauzy/ui-core

  • RegistrationConfigService asks GET /auth/registration/config once per page load and shares the answer. Only an explicit enabled: false reads as closed, so a web app talking to an older API (no such route) behaves as before.
  • registrationEnabledGuard (functional CanActivateFn) sends /auth/register to /auth/login while sign-up is closed.
  • *gaRegistrationEnabled (standalone structural directive) renders its content only after the server said sign-up is open, so a closed server never flashes the link.

@gauzy/ui-auth

  • The register route uses the guard; the Register links on sign-in, e-mail sign-in, find-workspace and forgot-password use the directive.

@gauzy/plugin-public-layout-ui

  • /share/workspace/create registers a new account and then a tenant, so it uses the same guard. Without it, a visitor would verify their e-mail address only to be refused by the API at the last step. Workspace sign-in and find stay open.

Configuration and deploy files

  • .env.sample documents the three variables; .env.docker lists them with their defaults; .env.compose and .env.demo.compose read them from the shell with a true default (${VAR:-true}); .env.local lists them commented out, because the API loads that file with override and an active true there would beat a false set in the shell or .env.
  • .github/workflows/deploy-do-app-platform-prod.yml: ADMIN_PASSWORD_RESET changed from 'false' to 'true'. The API ignored that value until now; leaving it would have silently turned off the e-mailed reset for admins on that deployment. 'true' keeps its behaviour unchanged. Every other deploy config was checked and already keeps today's behaviour: .do/app.yaml (passes the workflow value through), render.yaml, .render/render.demo.yaml, .fly/applications/api/fly.toml, all .deploy/k8s/*.yaml and the other deploy-do-* workflows set 'true'; the .deploy/ssh/** compose templates pass through what their workflows set ('true'). None of them sets APP_USER_REGISTRATION or ALLOW_TENANTS_REGISTRATION, so they default to true.

Deliberately not changed

  • POST /auth/signup.provider.social and POST /auth/signup.link.account are not gated: they look up and link existing accounts and create no user, and gating them would break social sign-in for existing users (Ever Teams uses them). A social sign-in with an unknown e-mail redirects to #/auth/register, which the new guard sends to sign-in, and the API refuses the registration anyway.
  • Invitation acceptance (/invite/accept*) does not go through /auth/register and is untouched.
  • The desktop apps' Register links open the web app's register page, which the guard now handles; the desktop UI itself is unchanged.

Flags / settings and their defaults

VariableDefaultRead by
APP_USER_REGISTRATIONtrue (unset or blank = true)environment.authSwitches.userRegistration
ALLOW_TENANTS_REGISTRATIONtrue (unset or blank = true)environment.authSwitches.tenantsRegistration
ADMIN_PASSWORD_RESETtrue (unset or blank = true)environment.authSwitches.adminPasswordReset

A value that is not one of true/false/1/0/yes/no/on/off leaves the switch on and logs a warning at startup.

Tests

New specs cover the parser and defaults, the policy service (including the empty-database bootstrap and the cached tenant check on both ORMs), the guard (alone, and together with the real RegisterAuthorizationGuard: admin token, no token, bad, expired, reset-purpose and non-admin tokens, inactive accounts, open registration), the reset rule, the HTTP behaviour of /auth/register and /auth/registration/config (guard order, 201 by default, 403 under each switch, an admin still creating a user while closed), tenant creation (default, closed, bootstrap, two simultaneous first onboardings, a retry while the first onboarding still runs, lock order on Postgres and MySQL, resuming an unfinished tenant, APP_USER_REGISTRATION alone not closing it), both reset routes (admin vs employee, mixed tenants, same answer as an unknown address, token not consumed), the Zitadel sign-up switch, and the web app pieces (service, guard, directive, the real sign-in pages, both routes).

All run locally on Node 26, from the repository root:

CommandResult
NODE_OPTIONS=--experimental-vm-modules npx jest -c packages/config/jest.config.ts --rootDir packages/config auth-switches1 suite, 21 tests passed
NODE_OPTIONS=--experimental-vm-modules npx jest -c packages/core/jest.config.ts --rootDir packages/core "auth-switches|auth.controller.registration-switch|auth.service.admin-password-reset|tenant.service|register-authorization.guard|auth.service.signup-email-verification|tenant.resolver"14 suites, 222 tests passed (new suites plus the existing tenant, register-guard and sign-up suites they touch)
NODE_OPTIONS=--experimental-vm-modules npx jest -c packages/plugins/auth-zitadel/jest.config.ts --rootDir packages/plugins/auth-zitadel "zitadel-signup.e2e|auth-zitadel.config"2 suites, 28 tests passed
npx jest -c packages/ui-core/jest.config.ts --rootDir packages/ui-core "registration-config|registration-enabled"3 suites, 9 tests passed
npx jest -c packages/ui-auth/jest.config.ts --rootDir packages/ui-auth "registration-switch|login-magic"2 suites, 6 tests passed
npx jest -c packages/plugins/public-layout-ui/jest.config.ts --rootDir packages/plugins/public-layout-ui registration-switch1 suite, 3 tests passed
npx tsc -p packages/{config,contracts,core,plugins/auth-zitadel}/tsconfig.lib.json --noEmitno errors
npx tsc -p packages/{ui-core,ui-auth}/tsconfig.lib.json --noEmitno errors in changed files (the existing google namespace and spec-runner typing errors in untouched files remain)
npx eslint <changed .ts files>no new errors (3 existing prefer-const errors in untouched lines of auth.service.ts)

The Angular specs need the generated packages/ui-config environment files (yarn config:dev).

Manual verification still needed

  • Run a local stack with each switch set to false and check: anonymous sign-up gets 403 and the Register links are gone; an admin can still add a user from the Users page; an invited user can accept the invitation; creating a tenant on a fresh empty database works; "Forgot password" for admin@ever.co answers like any other address and sends no e-mail, while an employee still receives one. I did not run the stack or a browser for this PR; the behaviour is covered by the unit and HTTP specs above.
  • Boot the API (Postgres, and MySQL if available) to confirm the AuthModule wiring, then with ALLOW_TENANTS_REGISTRATION=false on an empty database start two first onboardings at once (one gets 409), and retry one creator's onboarding while their first is still seeding (409). The specs mock the lock connection. A load of the full core module graph in a test run found no unresolved provider.
  • With registration closed, call POST /api/auth/register with an admin's token and only user/password (201), with an employee's token (403), and with no token (403).
  • Known limitation: on MikroORM, findUnfinishedTenant deliberately resumes nothing (its seeds are not transactional), so if the first tenant's onboarding fails half-way while ALLOW_TENANTS_REGISTRATION=false, the retry gets 403 (TypeORM resumes it). Setting the variable to true for the retry lets the creator finish.
  • Known limitation: with DB_POOL_SIZE=1 the API cannot spare a connection for the onboarding locks and uses only the in-process guard (as the comment on withOnboardingLock says). On such a deployment running several API processes, two first onboardings started at the same moment on an empty database could each create a tenant while ALLOW_TENANTS_REGISTRATION=false; every later tenant is then refused. On TypeORM with Postgres or MySQL, a pool of two or more connections takes the database lock and does not have this gap (SQLite and MikroORM use the in-process guard, as before this PR).

Risk and rollback

  • With no variable set, every path behaves as before: the guard passes, the tenant check is skipped, and every account can reset by e-mail. The only always-on additions are the public GET /auth/registration/config route (returns { enabled: true } on an open server) and one call to it from the web app.
  • Rollback: set the variable(s) back to true (or unset them) and restart the API; or revert this PR. No migration, no stored data.

Decisions and follow-ups

  1. The production and stage environments outside this repository were checked: they set ADMIN_PASSWORD_RESET=true and neither registration variable, so merging changes nothing live. Production stays 'true'.
  2. ADMIN_PASSWORD_RESET=false does not cover the e-mailed sign-in code (/auth/signin.email); that is a different flow and stays as it is.
  3. There is no per-tenant feature flag for registration (feature flags belong to a tenant, and sign-up happens before there is one). A database-backed setting, changeable without a restart, may follow later; not in this PR.
  4. docs.gauzy.co lives in ever-co/ever-gauzy-docs; its configuration page should list the three variables. Not part of this PR.

Closes #5784
Closes #7821

🤖 Generated with Claude Code