From Internal Registry to External User Database: How AitherDirectory Learned to Remember Your Users
Two posts ago, we built AitherDirectory — a unified LDAP-compatible directory tree that put every identity object in AitherOS under one roof. Agents, services, tenants, secrets, certificates. Everything got a DN, everything was queryable, everything was backed by SQLite WAL with sub-millisecond reads.
There was one glaring gap: external users didn't really live there.
When someone registered through AitherIdentity — typed in a username, email, and password on the sign-up form — their account was created in the RBAC system (SQLite rbac.db). AitherDirectory got a fire-and-forget copy via the IdentityDirectoryBridge. Best-effort. If Directory was down during registration, the sync was silently lost. The user could log in fine — RBAC had them — but the directory tree had no idea they existed.
For internal services and agents, this was acceptable. They re-register on every container boot anyway. For a human who typed in their email, picked a password, and clicked "Sign Up" — losing that record was not acceptable.
We needed AitherDirectory to become a primary store for external user registrations. Not a replica. Not a cache. A persistent, verified, queryable database of every person who has ever created an account.
What Changed
Six New Auth Endpoints
AitherDirectory (port 8214) now exposes a dedicated auth API:
| Endpoint | Purpose |
|---|---|
POST /v1/auth/register | Create an external user directly in Directory |
POST /v1/auth/verify | Verify password credentials, update login tracking |
POST /v1/auth/lookup | Find a user by ID, username, email, or OAuth |
PUT /v1/auth/update | Update auth fields (password, email, verification status) |
GET /v1/auth/users | List external users with source/status filters |
GET /v1/auth/stats | Registration statistics by source and verification status |
The registration endpoint validates uniqueness (username, email, and OAuth link), hashes passwords with bcrypt (cost 12), creates a full DirectoryEntry with LDAP-style attributes, and returns a safe public profile. No password hashes ever leave the API.
Twelve New Directory Attributes
Every user entry now carries auth-critical metadata as LDAP attributes:
aitherAuthSource → "external" | "oauth" | "relay" | "identity" | "internal"
aitherAccountStatus → "active" | "pending_verification" | "suspended" | "locked"
aitherRegistrationSource → "web" | "api" | "relay" | "oauth" | "admin"
aitherEmailVerified → "TRUE" | "FALSE"
aitherEmailVerifiedAt → ISO-8601 timestamp
aitherHumanityVerified → "TRUE" | "FALSE"
aitherHumanityScore → float (0.0–1.0)
aitherHumanityVerifiedAt → ISO-8601 timestamp
aitherLoginCount → integer
aitherLastLogin → ISO-8601 timestamp
aitherLastLoginIp → IP address string
aitherPasswordHash → bcrypt hash (never exposed via API)
These live in the JSON attributes column of DirectoryStore's SQLite backend — no DDL changes needed. We added partial indexes on the auth-critical attributes (mail, uid, aitherauthsource, oauthprovider, accountstatus) so lookups stay fast as the user base grows.
The Three-Tier Cascade
Here's where it gets architectural. AitherOS now has three places a user might live:
- PostgreSQL — the production backend when running at scale
- SQLite (
rbac.db) — the default local backend - AitherDirectory (
directory.db) — the new external user store
The RBAC Manager's _load_data() method now cascades through all three:
PostgreSQL → SQLite → AitherDirectory (external users)
After loading from its primary backend (PG or SQLite), the RBAC Manager calls _load_directory_externals(), which queries Directory for users with aitherAuthSource in ["external", "relay", "oauth"] and merges them into the in-memory user map. This means any user stored in Directory is automatically available for RBAC permission checks — without requiring a separate sync step.
If both PostgreSQL and SQLite are unavailable (fresh deployment, corrupted state), the RBAC Manager falls back to using DirectoryBackend as its sole data source. Three tiers. No single point of failure for user data.
Confirmed Sync (Not Fire-and-Forget)
The old bridge pattern was:
asyncio.create_task(directory_bridge.sync_user(user, actor="registration"))
Fire and forget. If Directory was slow, busy, or briefly down — the task silently failed.
For external registrations, we now use a confirmed sync path:
asyncio.create_task(
directory_bridge.sync_external_user(user, actor="registration", auth_source="external")
)
sync_external_user() retries up to three times with exponential backoff (0.5s, 1s, 1.5s). After a successful write, it performs a read-back verification — it reads the entry back from Directory and confirms the DN exists. If all three retries fail, the warning is logged at the service level. The user's RBAC record still exists (registration never fails because of a Directory issue), but the gap is visible in logs for operational follow-up.
Four places in AitherIdentity now use confirmed sync:
- Password registration →
auth_source="external", confirmed=True - OAuth callback (GitHub, Google, etc.) →
auth_source="oauth", confirmed=True - Cloudflare Access SSO →
auth_source="oauth", confirmed=True - Service registration →
auth_source="internal"(fire-and-forget is fine for services)
Dual-Write on Every Save
When the RBAC Manager saves a user (password change, role update, metadata update), it now dual-writes to Directory:
def _save_user(self, user):
# ... existing SQLite/PG save ...
if self._directory_backend:
try:
self._directory_backend.save_user(user)
except Exception:
pass # Non-blocking — RBAC is still the primary
This keeps Directory in sync with RBAC as a background benefit. The dual-write is best-effort (we don't want a Directory hiccup to break a password change), but over time it means Directory's copy converges with RBAC's.
Does This Actually Track Registered Emails?
Yes. Let's walk through the full lifecycle:
1. Registration. User signs up with username + email + password. AitherIdentity creates the RBAC user, then calls _dir_sync_user(user, auth_source="external", confirmed=True). Directory stores the entry with mail=user@example.com, aitherEmailVerified=FALSE, aitherAuthSource=external.
2. Welcome email. AitherIdentity sends a welcome email with a verification link containing a hashed token. The token is stored in email_verification_tokens.json with a TTL.
3. Email verification. User clicks the link. AitherIdentity's /auth/email/verify endpoint validates the token, sets metadata.email_verified = True in RBAC, upgrades the user's role from registered to starter, and then syncs the updated user back to Directory with confirmed sync. Directory now shows aitherEmailVerified=TRUE and aitherEmailVerifiedAt=<timestamp>.
4. Login tracking. Every successful credential check via Directory's /v1/auth/verify endpoint increments aitherLoginCount and updates aitherLastLogin.
5. Querying. The /v1/auth/stats endpoint can tell you exactly how many users have verified their email vs. how many are still pending. The /v1/auth/users endpoint lets you filter by auth source and account status.
6. Admin force-verify. Admins can force-verify a user's email via /admin/users/{user_id}/verify-email. This also syncs the verified status to Directory.
The key insight: email verification status flows in both directions. AitherIdentity is the ceremony host (sends the email, validates the token, upgrades the role). AitherDirectory is the permanent record keeper (stores the verified timestamp, makes it queryable, includes it in the stats).
The Tier System
This work also connects to the tier system we built for AitherRelay (the community IRC hub). Users progress through verification tiers:
| Tier | Level | Unlocked By |
|---|---|---|
| Guest | 0 | Show up |
| Bound | 1 | Link a nick to a session |
| Registered | 2 | Create an account (username + password) |
| Verified | 3 | Click the email verification link |
| Humanity Verified | 4 | Pass the humanity verification test |
Every tier unlocks more community features — polls, voting, announcements, threading. The Directory tracks both aitherEmailVerified and aitherHumanityVerified as first-class attributes. When you query a user's profile through the auth API, you can see exactly where they stand in the verification chain.
What We Didn't Build (Yet)
Rate limiting on auth endpoints. The registration and verify endpoints don't have their own rate limiter. AitherIdentity has capacity management and IP-based rate limiting on its registration flow, but the Directory's direct auth endpoints are currently unprotected. If we expose these publicly (rather than as internal service-to-service calls), they'll need rate limiting.
MFA/TOTP on Directory-native auth. AitherIdentity has full TOTP 2FA, WebAuthn/FIDO2 passkeys, and backup codes. The Directory's /v1/auth/verify endpoint only does password verification — it doesn't check 2FA. For now, the intended flow is: authenticate through Identity (which handles 2FA), and use Directory for storage and lookup. If Directory ever becomes a standalone auth endpoint for third-party integrations, it'll need its own 2FA gate.
SCIM provisioning push. We have SCIM support in AitherIdentity for pulling users from external IdPs. We don't yet have the reverse — pushing Directory users to external systems via SCIM. This matters when AitherOS users need accounts in third-party SaaS tools.
The Architecture Pattern
The broader pattern here is worth noting. AitherOS follows a layered identity architecture:
┌─────────────────────────────────────────┐
│ AitherIdentity (L8 Security) │ ← Ceremonies: login, OAuth, 2FA, tokens
│ Port 8112 │
├─────────────────────────────────────────┤
│ AitherRBAC (lib/security) │ ← Permissions: roles, groups, access control
│ In-process library │
├─────────────────────────────────────────┤
│ AitherDirectory (L0 Infrastructure) │ ← Records: persistent store, LDAP, audit
│ Port 8214 (REST) + 8389 (LDAP) │
└─────────────────────────────────────────┘
Identity handles the ceremonies — the interactive flows that authenticate a person. RBAC handles the permissions — the rules that determine what an authenticated person can do. Directory handles the records — the persistent, queryable, auditable store of who everyone is.
Previously, Identity and RBAC were tightly coupled (Identity writes to RBAC, RBAC is the store of record). Directory was a read-only mirror. Now, Directory is a full participant — a primary store for external users that feeds back into RBAC through the three-tier cascade.
The data flows both ways. Identity writes registrations down to Directory. RBAC reads external users up from Directory. Directory serves as the permanent record that survives container rebuilds, database migrations, and backend switches.
Conclusion
AitherDirectory started as a place to organize internal infrastructure — agents, services, certificates, tenants. With these changes, it's become the persistent backbone for external user management. Every registration is written with retry and verification. Every email verification propagates back to the directory tree. Every login is tracked. Every user is queryable by email, username, OAuth link, or user ID.
The directory tree doesn't just know about your services anymore. It knows about your users.
This post is part of the AitherOS engineering series. Previous entries on identity: AitherDirectory: One Tree to Rule Them All and Building a Full-Stack Identity & Directory Service.
David Parkhurst builds AI systems at Aitherium Labs.