Skip to content

Package: tenancy

Generated from api/packages/tenancy

Source: api/packages/tenancy. The sections after the README are extracted from the code on every docs build.

Provisions and removes tenants of a multi-domain ulams API. One API deployment serves the platform (api.localhost) and any number of tenants, each on its own host (<slug>.localhost) with its own:

  • PostgreSQL role and database (ulams_<slug>),
  • MinIO/S3 bucket with public read (ulams-<slug>),
  • .env.<host> file and storage directory (gecche/laravel-multidomain),
  • APP_KEY and Passport key pair (a token from one tenant is rejected by every other),
  • Redis key prefix for queues, cache and Horizon (ulams_<slug>_),
  • demo users and the public settings the front reads (global.companyName, global.frontURL, theme.theme, theme.accent),
  • optionally demo mode (tenants.demo → DEMO_MODE in the env file, see packages/demo).

The registry of tenants is the tenants table in the platform database. Secrets in it (db_password, app_key, Passport keys) are encrypted with the platform APP_KEY.

It also stops laravel-multidomain from serving unknown hosts with the platform .env: a global middleware answers 404 to any host that is neither a platform host (TENANCY_PLATFORM_HOSTS) nor a registered tenant with its own env file.

All commands run on the platform (no --domain).

Terminal window
# create or resume; finished steps are skipped
php artisan ulams:tenant:create coffee --name="The Coffee Atlas" --theme=coffee --accent="#C2552D" --users=5
# run one step again
php artisan ulams:tenant:create coffee --redo=migrate
# demo mode (packages/demo): DEMO_MODE=true in the env file, login without password,
# hourly reset; only the env file is rewritten
php artisan ulams:tenant:create coffee --demo=on
php artisan ulams:tenant:list
php artisan ulams:tenant:list --hosts # active API hosts, one per line
# rebuild env files, registrations and Passport keys from the tenants table (init.sh)
php artisan ulams:tenant:sync-env --migrate
# AI settings (ANTHROPIC_API_KEY, AI_*) are inherited from the platform env; per-tenant override (ADR 0063)
php artisan ulams:tenant:set-env acme --set=AI_DRIVER=disabled
# drop database, role, bucket, env file, storage directory and Redis keys
php artisan ulams:tenant:delete coffee --force

/api/platform/tenants and /api/platform/operations manage tenants over HTTP (ADR 0078, 0085). They exist only on a platform host with TENANCY_PLATFORM_API=true (404 otherwise), for users with the platform_admin permission and scoped tokens with platform:read|write. Creation and deletion are queued jobs (ProvisionTenantJob, DeleteTenantJob) that record each step in tenant_operations; TenantLifecycle is the code the artisan commands and the API share. CLI: ulams tenants …. Operator guide: the docs site, “Platform tenant API”.

Steps of ulams:tenant:create, recorded in tenants.steps:

Step What it does
database CREATE ROLE + CREATE DATABASE through the pgsql_admin connection
bucket creates the bucket and a public read policy (AWS SDK)
env domain:add <slug>.localhost with the tenant values
migrate migrate --force
passport_keys passport:keys, copy stored encrypted on the tenant row
passport_client passport:client --personal
permissions db:seed --class=PermissionsSeeder (creates the admin user)
lti_keys ulams:lti:rotate-keys --init: the tenant’s LTI signing keys (ADR 0012)
demo ulams:tenant:seed-demo: tutor, students, settings

Every step from migrate on runs as php artisan … --domain=<host> in a child process. An in-process Artisan::call would keep the platform configuration.

Demo users: admin@<slug>.ulams.app, tutor@<slug>.ulams.app, student1@<slug>.ulams.app … studentN@<slug>.ulams.app, all with the password from TENANT_DEMO_PASSWORD (default secret, development only).

See src/config.php and the tenancy section of docs/enviromental-variables.md.

Terminal window
vendor/bin/phpunit --testsuite tenancy

The end-to-end isolation test provisions two real tenants and is opt-in:

Terminal window
docker compose exec -T -e TENANCY_INTEGRATION=1 api vendor/bin/phpunit packages/tenancy/tests/Integration
Method Path Auth Action
GET /api/platform/tenants yes PlatformTenantController@index
POST /api/platform/tenants yes PlatformTenantController@store
GET /api/platform/tenants/{slug} yes PlatformTenantController@show
PATCH /api/platform/tenants/{slug}/env yes PlatformTenantController@env
DELETE /api/platform/tenants/{slug} yes PlatformTenantController@destroy
GET /api/platform/operations yes PlatformOperationController@index
GET /api/platform/operations/{id} yes PlatformOperationController@show

None.

Registered with AdministrableConfig::registerConfig (editable in the admin panel under Configuration → Settings).

None.

None.

Command Description Signature
ulams:db:recreate-views Recreate the searchable_events SQL view with the current model class names ulams:db:recreate-views
ulams:h5p:export-config Write the least-privilege env files and Passport public keys the H5P service mounts in production (H5P_SERVICE_CONFIG_DIR) ulams:h5p:export-config
ulams:tenant:create Provision a tenant (database, bucket, env file, migrations, keys, demo users). Safe to re-run: finished steps are skipped. ulams:tenant:create {slug : Lowercase letters and digits, used for hosts, database and bucket names} {--name= : Display name (APP_NAME and the global.companyName setting)} {--theme= : Front theme preset key, coffee, oncall, nightsky, gravity, poland or ulam} {--accent= : Accent colour, e.g. #C2552D} {--users=5 : Number of demo students} {--demo= : Demo mode (DEMO_MODE: login without password, hourly reset): on or off} {--db-password= : Password of a database you created yourself (TENANCY_DATABASE_PROVISIONER=manual), at least 16 characters} {--redo=* : Step to run again even if recorded as done: database, bucket, env, migrate, passport_keys, passport_client, permissions or demo}
ulams:tenant:delete Delete a tenant and all its data ulams:tenant:delete {slug} {--force : Required. Drops the database, role, bucket objects, env file and storage}
ulams:tenant:list List provisioned tenants ulams:tenant:list {--hosts : Print only the API hosts of active tenants, one per line}
ulams:tenant:schedule-loop Run the scheduler of this domain every minute in one long-lived process ulams:tenant:schedule-loop {--max-time=3600 : Exit after this many seconds (the supervisor starts it again with fresh code and config)} {--once : Run one scheduler tick now and exit} {--lock : With --once, claim the minute with the shared lock first (workers.sh lean mode: one process ticks every domain each minute)}
ulams:tenant:seed-demo Create demo users and public settings for the current tenant (internal step of ulams:tenant:create) ulams:tenant:seed-demo {--users=5 : Number of students} {--name= : Display name, stored as global.companyName} {--theme= : Front theme preset key, stored as theme.theme} {--accent= : Accent colour, stored as theme.accent} {--front-url= : Stored as global.frontURL} {--email-domain= : Domain of the demo e-mail addresses}
ulams:tenant:set-env Override or reset inheritable platform settings (AI key, driver, models) for one tenant ulams:tenant:set-env {slug} {--set=* : KEY=value override for this tenant (inheritable settings only, e.g. AI_DRIVER=fake)} {--unset=* : KEY to drop, so the tenant inherits the platform value again}
ulams:tenant:sync-env Rebuild .env. files, domain registrations and Passport keys of all tenants from the tenants table ulams:tenant:sync-env {--migrate : Also run pending migrations for every tenant}
ulams:tenant:work-once Run the scheduler tick and drain a queue once, then exit (for cron instead of long-lived workers) ulams:tenant:work-once {--queue=default,broadcast,video : Comma-separated queues to drain, highest priority first} {--connection= : Queue connection (default: the default connection; builder and long jobs use <driver>-builder and <driver>-long-job)} {--timeout=60 : Seconds a single job may run (builder jobs run up to 1800, long jobs up to 18000)} {--max-time=50 : Stop taking new jobs after this many seconds} {--memory=256 : Memory limit of the worker in MB} {--schedule : Run one scheduler tick before the queue (only on the line that runs every minute)}
ulams:upgrade Bring the platform and every tenant to the current release: migrations, permissions, keys and one-off data steps ulams:upgrade {--tenant=* : Only these tenant slugs (the platform is then left out)} {--platform-only : Only the platform} {--force-step=* : Run this one-off step again even if it already ran} {--dry-run : List what would run; change and record nothing}

None.

AI_DRIVER, AI_MODEL_DEFAULT, AI_MODEL_DEFAULT_LABEL, AI_MODEL_LIGHT, AI_MODEL_LIGHT_LABEL, AI_MODEL_PREMIUM, AI_MODEL_PREMIUM_LABEL, ANTHROPIC_API_KEY, ANTHROPIC_BASE_URL, AWS_USE_PATH_STYLE_ENDPOINT, H5P_SERVICE_CONFIG_DIR, INITIAL_USER_EMAIL, INITIAL_USER_PASSWORD, TENANCY_ADMIN_CONNECTION, TENANCY_ADMIN_HOST, TENANCY_API_HOST, TENANCY_BUCKET, TENANCY_BUCKET_PUBLIC_URL, TENANCY_CONTENT_HOST, TENANCY_DATABASE, TENANCY_DATABASE_PROVISIONER, TENANCY_EMAIL_DOMAIN, TENANCY_ENFORCE_HOSTS, TENANCY_FRONT_HOST, TENANCY_NEW_SITES, TENANCY_PHP_BINARY, TENANCY_PLATFORM_API, TENANCY_PLATFORM_HOST, TENANCY_PLATFORM_HOSTS, TENANCY_PROCESS_TIMEOUT, TENANCY_REDIS_PREFIX, TENANCY_S3_ENDPOINT, TENANCY_S3_KEY, TENANCY_S3_PUBLIC_READ_POLICY, TENANCY_S3_REGION, TENANCY_S3_SECRET, TENANCY_SCHEDULER_LOCK, TENANCY_SCHEME, TENANCY_STORAGE_PUBLIC_URL, TENANT_DEMO_PASSWORD, TENANT_SLUG