feat(auth): operator switches to close sign-up, tenant creation and admin e-mail password reset
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.
| Variable | Default | What false does |
|---|---|---|
APP_USER_REGISTRATION | true | Closes 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_REGISTRATION | true | Stops 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_RESET | true | Admin 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/registeris public. - #7821 reports a production server where anyone could sign up and create their own organization. The owner proposed
ALLOW_TENANTS_REGISTRATIONandADMIN_PASSWORD_RESETthere (both defaulttrue). Neither existed:ALLOW_TENANTS_REGISTRATIONwas not referenced anywhere, andADMIN_PASSWORD_RESETwas 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)andisSelfServiceSignupAllowed(switches).environment.authSwitchesinenvironment.ts/environment.prod.ts, typed inIEnvironment.
@gauzy/contracts
IRegistrationConfig { enabled: boolean }, the shape ofGET /api/auth/registration/config.
@gauzy/core
auth/auth-switches/(exported from@gauzy/corefor 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 oneEXISTSquery (soft-deleted rows included) and is cached once a tenant has been seen. Works on TypeORM and MikroORM.SelfRegistrationGuardonPOST /auth/register, afterRegisterAuthorizationGuard. 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 byRegisterAuthorizationGuard; for a body without them, the guard asksRegisterAuthorizationGuard.isAdministratorRequest(), which verifies the token with the same code as a privileged registration (now extracted intoauthenticateAdministrator: 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.AuthModulealso providesRegisterAuthorizationGuardso it can be injected.canResetPasswordByEmail(user): false only for Admin and Super Admin roles whileADMIN_PASSWORD_RESET=false.
AuthController: new publicGET /auth/registration/config→{ enabled }, documented in Swagger; 403 documented onPOST /auth/register.AuthService.requestResetPasswordsends 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.resetPasswordrefuses such an account's token before consuming it, with the same generic "Password Reset Failed." the endpoint already returns.TenantService.createOrResumeTenant(behind both RESTPOST /tenantand GraphQLcreateTenant) 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 / MySQLGET_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.AuthModuleregisters the tenant repositories the policy reads.
@gauzy/plugin-auth-zitadel
- The Ever ID sign-up path (
ZITADEL_SIGNUP_ENABLED, Ever Cloud only) stays closed whileAPP_USER_REGISTRATIONorALLOW_TENANTS_REGISTRATIONisfalse, with a startup warning.
@gauzy/ui-core
RegistrationConfigServiceasksGET /auth/registration/configonce per page load and shares the answer. Only an explicitenabled: falsereads as closed, so a web app talking to an older API (no such route) behaves as before.registrationEnabledGuard(functionalCanActivateFn) sends/auth/registerto/auth/loginwhile 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/createregisters 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.sampledocuments the three variables;.env.dockerlists them with their defaults;.env.composeand.env.demo.composeread them from the shell with atruedefault (${VAR:-true});.env.locallists them commented out, because the API loads that file with override and an activetruethere would beat afalseset in the shell or.env..github/workflows/deploy-do-app-platform-prod.yml:ADMIN_PASSWORD_RESETchanged 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/*.yamland the otherdeploy-do-*workflows set'true'; the.deploy/ssh/**compose templates pass through what their workflows set ('true'). None of them setsAPP_USER_REGISTRATIONorALLOW_TENANTS_REGISTRATION, so they default totrue.
Deliberately not changed
POST /auth/signup.provider.socialandPOST /auth/signup.link.accountare 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/registerand 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
| Variable | Default | Read by |
|---|---|---|
APP_USER_REGISTRATION | true (unset or blank = true) | environment.authSwitches.userRegistration |
ALLOW_TENANTS_REGISTRATION | true (unset or blank = true) | environment.authSwitches.tenantsRegistration |
ADMIN_PASSWORD_RESET | true (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:
| Command | Result |
|---|---|
NODE_OPTIONS=--experimental-vm-modules npx jest -c packages/config/jest.config.ts --rootDir packages/config auth-switches | 1 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-switch | 1 suite, 3 tests passed |
npx tsc -p packages/{config,contracts,core,plugins/auth-zitadel}/tsconfig.lib.json --noEmit | no errors |
npx tsc -p packages/{ui-core,ui-auth}/tsconfig.lib.json --noEmit | no 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
falseand 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" foradmin@ever.coanswers 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
AuthModulewiring, then withALLOW_TENANTS_REGISTRATION=falseon 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/registerwith an admin's token and onlyuser/password(201), with an employee's token (403), and with no token (403). - Known limitation: on MikroORM,
findUnfinishedTenantdeliberately resumes nothing (its seeds are not transactional), so if the first tenant's onboarding fails half-way whileALLOW_TENANTS_REGISTRATION=false, the retry gets 403 (TypeORM resumes it). Setting the variable totruefor the retry lets the creator finish. - Known limitation: with
DB_POOL_SIZE=1the API cannot spare a connection for the onboarding locks and uses only the in-process guard (as the comment onwithOnboardingLocksays). 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 whileALLOW_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/configroute (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
- The production and stage environments outside this repository were checked: they set
ADMIN_PASSWORD_RESET=trueand neither registration variable, so merging changes nothing live. Production stays'true'. ADMIN_PASSWORD_RESET=falsedoes not cover the e-mailed sign-in code (/auth/signin.email); that is a different flow and stays as it is.- 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.
- docs.gauzy.co lives in
ever-co/ever-gauzy-docs; its configuration page should list the three variables. Not part of this PR.
🤖 Generated with Claude Code