Platform Admin Provisioning
The Tower is Mallnline's internal platform administration dashboard. Access is controlled by a dedicated platform_admins table in the auth Postgres database โ completely decoupled from Organization membership. This means a Malet Owner who creates an Organization does not automatically become a platform admin.
IMPORTANT
Platform admin access is restricted to @mallnline.com email addresses. This is enforced at the API level.
Architecture
The Problem (Pre-Provisioning)
Previously, is_privileged_user() in the auth service queried org_members for OWNER or ADMIN roles. This meant:
- Any Malet Owner who created an Organization automatically got Tower access
- No way to distinguish between "Org Admin" and "Platform Admin"
- No audit trail for who granted platform access
The Solution
A dedicated platform_admins table with:
- Invite-chain provisioning โ all admins trace back to a seeded
SUPER_ADMIN - 3-tier role system โ
SUPER_ADMIN,ADMIN,VIEWER - Soft-delete revocation โ
revoked_attimestamp for access removal - Domain restriction โ only
@mallnline.comemails
โโโโโโโโโโโโโโโ invite โโโโโโโโโโโโโโโ invite โโโโโโโโโโโโโโโ
โ SUPER_ADMIN โโโโโโโโโโโโโโโโโถโ ADMIN โ โ โ VIEWER โ
โ (seeded) โ โ (can manage) โ โ (read-only) โ
โโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโ
โ โฒ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
invite
Only SUPER_ADMIN users can send invites. ADMIN and VIEWER roles cannot escalate.
Database Schema
`platform_admins` Table
| Column | Type | Description |
|---|---|---|
id |
UUID (PK) | Auto-generated |
user_id |
UUID (FK โ users, UNIQUE) | One admin record per user |
role |
platform_admin_role enum |
SUPER_ADMIN, ADMIN, VIEWER |
granted_by |
UUID (FK โ users, nullable) | NULL for seed records |
granted_at |
TIMESTAMPTZ | When access was granted |
revoked_at |
TIMESTAMPTZ (nullable) | NULL = active, set = revoked |
`platform_admin_invites` Table
| Column | Type | Description |
|---|---|---|
id |
UUID (PK) | Auto-generated |
email |
VARCHAR(255) | Must end with @mallnline.com |
role |
platform_admin_role enum |
Role to grant on acceptance |
token |
VARCHAR(128, UNIQUE) | UUID + 64-char random token |
invited_by |
UUID (FK โ users) | The SUPER_ADMIN who sent the invite |
expires_at |
TIMESTAMPTZ | 72 hours from creation |
accepted_at |
TIMESTAMPTZ (nullable) | NULL = pending, set = accepted |
created_at |
TIMESTAMPTZ | Auto-set |
REST API
All endpoints are on the auth service and require authentication via session cookie.
`POST /admin/invite`
Create a platform admin invite. SUPER_ADMIN only.
// Request
{
"email": "alice@mallnline.com",
"role": "ADMIN" // or "VIEWER", defaults to "ADMIN"
}
// Response (200)
{
"message": "Invite created successfully",
"email": "alice@mallnline.com",
"role": "ADMIN",
"token": "a1b2c3...96chars",
"expires_at": "2026-04-22T18:00:00Z"
}
Error responses:
| Status | Condition |
|---|---|
400 |
Email doesn't end with @mallnline.com |
400 |
Role is not ADMIN or VIEWER |
403 |
Caller is not SUPER_ADMIN |
409 |
User is already a platform admin |
409 |
Pending invite already exists for this email |
`POST /admin/invite/accept`
Accept an invite. Any authenticated user (must match invite email).
// Request
{ "token": "a1b2c3...96chars" }
// Response (200)
{
"message": "Welcome to The Tower",
"role": "ADMIN"
}
Error responses:
| Status | Condition |
|---|---|
403 |
Authenticated user's email doesn't match invite |
404 |
Invalid token |
409 |
Invite already accepted |
410 |
Invite expired (72h TTL) |
`GET /admin/admins`
List all active platform admins. Any platform admin can view.
// Response (200)
{
"admins": [
{
"id": "uuid",
"user_id": "uuid",
"role": "SUPER_ADMIN",
"granted_by": null,
"granted_at": "2026-04-19T00:00:00Z",
"email": "meekdenzo@gmail.com",
"username": "meekdenzo",
"display_name": null
}
],
"total": 2
}
`DELETE /admin/admins/:id`
Revoke a platform admin. SUPER_ADMIN only. Soft-deletes by setting revoked_at.
// Response (200)
{
"message": "Platform admin access revoked",
"revoked_user_id": "uuid"
}
Error responses:
| Status | Condition |
|---|---|
400 |
Cannot revoke your own access |
403 |
Caller is not SUPER_ADMIN |
404 |
Admin record not found |
Role Capabilities
| Capability | SUPER_ADMIN | ADMIN | VIEWER |
|---|---|---|---|
| View Tower dashboard | โ | โ | โ |
| View analytics | โ | โ | โ |
| View user listing | โ | โ | โ |
| View platform admins | โ | โ | โ |
| Send admin invites | โ | โ | โ |
| Revoke admin access | โ | โ | โ |
| Destructive actions | โ | โ | โ |
Session TTL Impact
Platform admins receive shorter session TTLs for security:
| Session Type | Platform Admin | Regular User |
|---|---|---|
| Session token | 15 minutes | 60 minutes |
| Refresh token | 7 days | 30 days |
Frontend Integration
Auth Store
The /me endpoint now returns platform_admin_role alongside is_privileged:
// $currentUser
{
id: "uuid",
email: "meekdenzo@gmail.com",
is_privileged: true,
platform_admin_role: "SUPER_ADMIN", // new field
// ...
}
Tower Routes
| Route | Description | Access |
|---|---|---|
/tower |
Main dashboard | is_privileged |
/tower โ Team tab |
Admin management | All platform admins (Invite/Revoke: SUPER_ADMIN only) |
/tower/accept-invite?token=xxx |
Invite acceptance | Authenticated + matching email |
Navigation Gating
The Tower link in SideNav, UserMenu, and footer is conditionally rendered based on $currentUser?.is_privileged.
Seed Migration
The initial SUPER_ADMINs are seeded by email lookup (not hardcoded UUIDs), ensuring the migration survives database wipes:
INSERT INTO platform_admins (user_id, role)
SELECT id, 'SUPER_ADMIN'::platform_admin_role
FROM users
WHERE email IN ('meekdenzo@gmail.com', 'mallnline.dev@gmail.com')
ON CONFLICT (user_id) DO NOTHING;
Component State
The Team tab in +page.svelte uses the following Svelte 5 runes state:
| State Variable | Type | Description |
|---|---|---|
teamAdmins |
Array<{id, user_id, role, granted_by, granted_at, email, username, display_name}> |
Fetched admin list |
teamLoading |
boolean |
Loading state for admin list |
teamError |
string |
Error message from fetch |
showInviteModal |
boolean |
Invite modal visibility |
inviteEmail |
string |
Email input binding |
inviteRole |
'ADMIN' | 'VIEWER' |
Role selector binding |
inviteSubmitting |
boolean |
Invite submission in progress |
inviteMessage |
string |
Success toast text |
inviteError |
string |
Invite error text |
isSuperAdmin |
boolean (derived) |
user?.platform_admin_role === 'SUPER_ADMIN' |
File Reference
| File | Purpose |
|---|---|
src/routes/tower/+page.svelte |
Main Tower dashboard with Team tab (lines 835โ950) |
src/routes/tower/+page.ts |
SSR disabled (ssr: false) |
src/routes/tower/accept-invite/+page.svelte |
Invite acceptance page |
src/routes/tower/accept-invite/+page.ts |
SSR disabled |
src/stores/auth.ts |
User interface with platform_admin_role field |
src/lib/services/auth.ts |
refreshAuth() used after invite acceptance |
tests/platformAdmin.test.ts |
Unit tests (25 cases) |
tests/tower-team.test.ts |
E2E tests (5 cases) |
Related
- Workspaces & The Tower โ Tower route architecture and Deck โ Tower separation
- User Listing API โ Admin-only user management queries consumed by Tower
- Admin Analytics Dashboards โ Revenue, Blog, Media, Search, Alerts analytics in The Tower
- Corporate Identity (SAML & SCIM) โ Enterprise SSO and provisioning (complementary to platform admin provisioning)
- User Identity Resolution โ Shared utility for resolving admin user IDs to display names across Tower and org dashboards