Skip to content

External identity and application state

M2 adds the first real external-service contracts. LDAP is the default identity source and PostgreSQL remains the application-state source. Gulo Gulo never sends credentials to the browser.

The standalone target (src/platform/standalone/) additionally supports a PostgreSQL-backed local_users table as an explicit, opt-in alternative to LDAP (identity.source = 'database', see "Database-backed identity" below) — for lighter single/few-tenant installs that would rather not stand up an external directory. LDAP stays the default: an operator who never sets identity.source keeps the LDAP-only behavior described below unchanged. cPanel and Plesk are unaffected — they always use their own panel API and never fall back to a local password store.

LDAP

Enable LDAP only when all three references are configured:

LDAP_ENABLED=true
LDAP_URL=ldaps://ldap.example.test:636
LDAP_BIND_DN=cn=gulogulo,ou=services,dc=example,dc=test
LDAP_BIND_SECRET_REF=ldap/bind
LDAP_USER_BASE_DN=ou=users,dc=example,dc=test

LDAP_BIND_SECRET_REF is a reference, not a password. A deployment-specific secret resolver supplies the value to the adapter at runtime. The value is not written to configuration output, logs, health responses, or audit records.

LDAPS verifies the server certificate. A plain ldap:// endpoint is accepted only with LDAP_STARTTLS=true, and StartTLS uses certificate verification as well. Embedded credentials in the URL are rejected. Connection and operation timeouts, pool size, and bounded retry attempts are explicit configuration values; retries use short exponential backoff and never log bind material.

User lookup is tenant-aware. The adapter builds a parameter-free LDAP filter that includes the requested local username and the tenant domain, requests only identity attributes (uid, mail, displayName, cn, and active), and rejects ambiguous results. Password authentication binds a short-lived client as the resolved user DN. A failure always returns an authentication failure; it is never converted into a local fallback login.

Database-backed identity (standalone only)

Enable the DB-backed identity source instead of LDAP with:

IDENTITY_SOURCE=database
POSTGRES_ENABLED=true

src/runtime/config.ts rejects IDENTITY_SOURCE=database unless POSTGRES_ENABLED=true — a local-users table with no database to live in is a configuration error, not a silent fallback.

Users live in the local_users table (src/core/db/migrations/0002_standalone_local_identity.sql): one row per tenant-scoped username, with the same forced row-level security as every other tenant-scoped table (gulogulo.tenant_id, set per transaction). Password verification reuses createPasswordHasher() (src/core/auth/password-hashing.ts — versioned scrypt, the same hasher used elsewhere in the project) instead of a second hashing scheme. src/platform/standalone/db-identity-client.ts builds the actual local_users connection by reusing createPostgresStore() (withTenantTransaction()), not a separate pool.

This repository does not yet ship an admin flow to create/rotate local_users rows; provisioning one today means inserting a row with createPasswordHasher().hash(password) as password_hash directly, e.g. through psql or a one-off script. A proper admin UI/API for local user management is tracked as follow-up work, not implemented by this change.

PostgreSQL

Enable PostgreSQL with a secret reference to the complete connection string:

POSTGRES_ENABLED=true
POSTGRES_HOST=postgres.example.test
POSTGRES_PORT=5432
POSTGRES_DB=gulogulo
POSTGRES_USER=gulogulo
POSTGRES_DSN_SECRET_REF=postgres/dsn
POSTGRES_SSLMODE=verify-full

verify-full is the default and requires certificate and hostname verification. require encrypts the connection but deliberately does not claim certificate verification. The adapter configures bounded pool size, connection timeout, idle timeout, and retry attempts.

Run migrations through the store before enabling stateful traffic:

const store = createPostgresStore({ config, resolveSecret, logger });
await store.runMigrations();

Migrations take a PostgreSQL advisory transaction lock, record a SHA-256 checksum in schema_migrations, and fail closed if an already-applied file is modified. The first migration creates tenant, policy, user-reference, quota, alias, delegation, role, and audit-reference tables. Tenant-scoped tables use forced row-level security and the store sets gulogulo.tenant_id inside every transaction before it can read or mutate data.

Tenant and quota contract

Every state operation requires a canonical tenant context containing tenant ID, domain, actor, and role. A different tenant ID is rejected before a query is issued; PostgreSQL RLS is the second, database-side boundary.

The tenant's gross quota is immutable after bootstrap. User allocations are locked and summed in the same transaction that creates or changes an allocation. An allocation is rejected when the sum would exceed the gross quota. This check is performed by the Gulo Gulo application's state adapter and repeated by PostgreSQL constraints/RLS; it is not delegated to a client or a host-side script.

Failure behavior

LDAP and PostgreSQL outages are visible to the dependency health contract and fail closed for authentication and administrative writes. No password, DSN, message body, or mailbox content is included in an error or audit reference. The M2 unit tests use deterministic fake clients. A real-PostgreSQL integration test also exists (src/integrations/postgres.integration.test.ts) and runs when GULOGULO_M2_POSTGRES_DSN is set, but no current CI workflow sets it — the disposable-database service that used to provide it belonged to the retired Docker-Compose CI model, and has not been readapted to the package-based workflows. The test itself still passes locally against a real PostgreSQL instance; wiring it back into CI is open follow-up work, not a code gap.