Toast
ContributorDecisions

ADR: Unified User Model vs Staff/Member Split

ADR: Unified User Model vs Staff/Member Split

Status: Proposed Date: 2026-02-24 Deciders: Hannah (CTO)


Context

Ghost maintains two separate database tables for user concepts: staff_users (authors, editors, admins) and members (subscribers, readers). These have separate authentication flows, separate session types, and separate data schemas. Toast inherits this split from Ghost.

The split was designed to prevent privilege escalation — a member record has no concept of admin permissions at the schema level, so a bug in member-facing code cannot accidentally grant admin access.

Two product realities challenge whether this split should continue into Toast:

1. The shadow member account problem. Staff who publish members-only content need to be able to read it as a member without creating a separate account. The Toast PRD's current solution is to auto-create a linked member record for every staff user. This is a workaround that the architecture itself is demanding — it indicates the two concepts are not as distinct as the schema implies.

2. The impersonation requirement. Staff need to view the site as a member to debug issues, which requires session-swapping infrastructure. In a unified model, impersonation becomes a single mechanism — "become this user" — without the two-hop flow Ghost currently requires (support→staff→member). See ADR: Access Model for the full impersonation design.

Ghost's product direction is toward community features, where a person's role is not binary (staff or member) but a graduated set of permissions. The split makes this movement architecturally painful: promoting a member to a contributor means creating a new entity in a different table, linking them, and managing two identities for the same person in perpetuity.

Note: This ADR supersedes the PRD's original requirement that "Staff/member table separation preserved for security boundaries." The security intent — preventing privilege escalation — is preserved through the defense-in-depth stack described below. The PRD has been updated accordingly. See also ADR: Access Model for how permissions, tokens, roles, and actors fit together.


Decision Drivers

  • Toast should adopt the architecture that will serve it longest — not the one inherited by default from Ghost
  • The split's primary justification is security. The central question is whether it meaningfully solves security, or whether the same protection is achieved by other controls that are required regardless

Options Considered

Option A: Retain the staff/member split

Keep two tables, two auth flows, two session types. Continue with shadow member accounts and impersonation infrastructure as described in the Toast PRD.

Advantages:

  • Structural guarantee: member-facing code physically cannot touch admin permissions, regardless of bugs or contributor discipline
  • Complexity firewall: staff-world and member-world code are kept separate at the schema level, constraining blast radius
  • No migration risk within Toast

Disadvantages:

  • Shadow member accounts are a permanent workaround, not a real solution
  • Impersonation requires a two-hop flow (support→staff→member) due to the type boundary between tables
  • Encodes a creator/consumer binary that the product is moving away from
  • Graduated permissions (member → contributor → moderator → admin) require painful cross-table promotion logic

Option B: Unified user model with permission-based access control

Single users table. Permissions stored in a separate user_permissions table. A PermissionService is the sole module authorised to modify permissions. Auth flows unified; session carries permission claims.

Advantages:

  • Shadow member accounts eliminated — staff are members with additional permissions
  • Impersonation becomes a single mechanism — "become this user" — without type boundary hops
  • Graduated permissions are a grant, not a schema migration
  • Enables community product evolution without architectural rework

Disadvantages:

  • Trades a structural guarantee for a disciplinary one — PermissionService must be treated as genuinely load-bearing and never bypassed
  • A unified model with poor discipline is worse than the split; there is no structural floor

Security Analysis

This is the crux of the decision. The split's security value must be assessed honestly against the actual attack vectors.

What the split genuinely prevents

Permission modification via application bug. A bug in member-facing code that attempts to grant admin permissions fails structurally — the members table has no permission columns, no foreign keys to staff permissions. This is the split's one genuine structural win.

What the split does not prevent

Mass assignment. An attacker sends { "role": "admin" } in a request body. Prevented by Zod input validation schemas at the API layer, which explicitly allowlist writable fields. Table structure is irrelevant.

SQL injection. If parameterised queries (Drizzle) are bypassed, the attacker has catastrophic access regardless of table structure. The split provides no meaningful protection.

Broken object-level authorisation (BOLA/IDOR). Purely an authorisation middleware problem. The split doesn't help.

Session confusion. An attacker presents a member session to the admin API. In the unified model, middleware checks: resolve session → resolve user → verify permission. The vulnerability is forgetting the permission check — prevented by correct middleware, not table structure.

Code injection via stored XSS. Ghost and Toast allow JavaScript injection as a feature (code injection settings, HTML cards). A malicious author could publish JS that executes in an admin's browser and makes authenticated requests using the admin's session. The split does not protect against this. The attack operates at the browser/session layer. What protects against it is admin domain separation — Ghost already runs the admin panel on a separate subdomain, so admin session cookies are inaccessible to JS on the public site. Toast must preserve this regardless of which option is chosen.

Defense-in-depth: the layers that replace the structural guarantee

Rather than relying on table separation, Toast needs defense-in-depth at the layers where attacks actually happen:

  1. Content Security Policy — restrict what scripts can execute. Non-trivial for Toast because the product allows script injection as a feature — needs different policies for admin vs public site.
  2. HttpOnly + SameSite cookies — session cookies marked HttpOnly can't be read by JavaScript. SameSite prevents them being sent on cross-origin requests.
  3. Admin domain separation — admin panel on a separate subdomain. Cookies scoped to the admin subdomain are inaccessible to JS on the public site. Stronger isolation than separate database tables.
  4. CSRF protection — CSRF tokens on all state-changing requests. Prevents cross-site request forgery where a malicious page triggers authenticated actions.
  5. API validation (Zod) — every endpoint has an explicit schema with allowlisted fields. No path to pass permission data through a profile update schema.
  6. Authorisation middleware — every route declares required permissions via requirePermission(). Routes without permission declarations fail CI.
  7. PermissionService isolation — sole module that can modify user permissions. Enforced by ESLint guard rule (ADR-006 pattern).
  8. Database constraints + RLS — PostgreSQL RLS on user_permissions restricts modifications to a specific database role. Permission enum rejects invalid values.
  9. Step-up authentication — dangerous actions (admin.manage_staff, site.delete, site.billing) require recent password/2FA confirmation.
  10. Audit logging — every permission change logged with full actor attribution. Anomalous grants can trigger alerts.

Security posture: what Toast has today

LayerStatusDetail
1. Content Security PolicyMust be builtNon-trivial because the product allows script injection as a feature. Needs different headers for admin vs public site.
2. HttpOnly + SameSite cookiesPartially existsBetter Auth handles session cookies (ADR-005). Requires explicit audit to confirm attributes match security model.
3. Admin domain separationStructurally existsAdmin is a separate Vite SPA on its own Railway subdomain. CORS restricts origins via CORS_ORIGIN env var. Must be preserved as a deliberate security boundary.
4. CSRF protectionMust be builtNo CSRF middleware exists. Hono has hono/csrf middleware available. Better Auth may handle CSRF for auth endpoints but API routes need independent protection.
5. Zod validation on every endpointPattern establishedContent routes use zValidator with explicit field allowlists via CreateContentSchema. Extending to user profile endpoints that reject permission fields is the same pattern.
6. Permission-based authorisation middlewareMust be builtToday's requireAuth() is a binary check. Needs requirePermission('content.publish') per route.
7. PermissionService isolationMust be builtNo permissions table or service exists. Current schema has a staff table with no role column.
8. Database constraints + RLS on permissionsMust be builtRLS planned for tenant isolation (Gate B) but not yet implemented.
9. Step-up authenticationMust be builtBetter Auth supports 2FA via plugins. Dangerous actions should require recent strong authentication.
10. Audit loggingProduction-readyEvent system (ADR-007) with typed domain events, audit log subscriber, audit_logs table with actor tracking.

Enforcement: how PermissionService discipline is maintained

1. ESLint guard rule (consistent with ADR-006). A toast/no-direct-permission-writes rule flags any file that imports the userPermissions Drizzle schema object outside of permissions.repository.ts. CI-blocking.

2. Architectural test for route permission declarations. A Vitest test imports all route definitions and asserts every non-public route has requirePermission() middleware. CI-blocking.

3. RLS policy on user_permissions table. Restricts INSERT/UPDATE/DELETE to a specific database role used only by the PermissionService's connection.

4. Service/repository architecture (ADR-004) as natural containment. The existing controller → service → repository pattern applied to a security-critical domain.


Schema Sketch

Directional, not final.

Note on ID types: The current staff table uses text primary keys because Better Auth generates 32-char alphanumeric string IDs (ADR-005). The sketches below use uuid to indicate intent — the actual column type will depend on whether we continue using Better Auth's ID generation or switch to self-generated UUIDs. This is an implementation detail to resolve when building the migration, not an architectural decision.

Note on citext: Email columns use PostgreSQL's citext extension for case-insensitive comparison (already established in ADR-005). This requires CREATE EXTENSION IF NOT EXISTS citext in the migration.

export const permissionEnum = pgEnum('permission', [
  // Content
  'content.create',
  'content.edit_own',
  'content.edit_all',
  'content.publish',
  'content.delete',
  // Members
  'members.view',
  'members.manage',
  // Site
  'site.settings',
  'site.billing',
  'site.delete',
  // Administration
  'admin.access',
  'admin.manage_staff',
  // Impersonation
  'users.impersonate',
]);

/**
 * Unified users tableevery person (reader, author, admin) is a user.
 * A user's permissions determine what they can do, not which table they're in.
 *
 * Identity + minimal public profile: name, slug, and avatar are on this table
 * because they're needed in many contexts (comments, admin panel, audit logs),
 * not just author pages. Domain-specific data lives in extension tables.
 */
export const users = pgTable(
  'users',
  {
    id: uuid('id').defaultRandom().primaryKey(),
    siteId: uuid('site_id')
      .notNull()
      .references(() => sites.id, { onDelete: 'cascade' }),
    email: citext('email').notNull(),
    name: text('name'),
    slug: text('slug'), // URL-safe identifier, used for author pages, public profiles
    avatar: text('avatar'), // profile image URL, used in admin UI, comments, author cards
    createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
    updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
  },
  (table) => [
    uniqueIndex('uq_users_site_id').on(table.siteId, table.id),
    uniqueIndex('uq_users_site_email').on(table.siteId, table.email),
    uniqueIndex('uq_users_site_slug').on(table.siteId, table.slug),
    index('idx_users_site_id').on(table.siteId),
  ]
);

/**
 * User permissions — the sole source of truth for what a user can do.
 * Modified ONLY through PermissionService.
 */
export const userPermissions = pgTable(
  'user_permissions',
  {
    id: uuid('id').defaultRandom().primaryKey(),
    siteId: uuid('site_id')
      .notNull()
      .references(() => sites.id, { onDelete: 'cascade' }),
    userId: uuid('user_id').notNull(),
    permission: permissionEnum('permission').notNull(),
    grantedBy: uuid('granted_by'),
    grantedAt: timestamp('granted_at', { withTimezone: true }).defaultNow().notNull(),
    expiresAt: timestamp('expires_at', { withTimezone: true }), // NULL = permanent
  },
  (table) => [
    uniqueIndex('uq_user_permissions').on(table.siteId, table.userId, table.permission),
    index('idx_user_permissions_site_user').on(table.siteId, table.userId),
    foreignKey({
      name: 'fk_user_permissions_user_site',
      columns: [table.siteId, table.userId],
      foreignColumns: [users.siteId, users.id],
    }).onDelete('cascade'),
    foreignKey({
      name: 'fk_user_permissions_granted_by_site',
      columns: [table.siteId, table.grantedBy],
      foreignColumns: [users.siteId, users.id],
    }).onDelete('set null'),
  ]
);

/**
 * Author-specific profile data. Created when a user first publishes content
 * or is granted content.create permission.
 *
 * The users table provides name, slug, and avatar — enough for a basic author
 * card. This table adds publishing-context fields: bio, social links, cover
 * image, SEO metadata. A subscriber commenting on a post doesn't need these.
 */
export const authorProfiles = pgTable(
  'author_profiles',
  {
    userId: uuid('user_id').primaryKey(),
    siteId: uuid('site_id')
      .notNull()
      .references(() => sites.id, { onDelete: 'cascade' }),
    bio: text('bio'),
    coverImage: text('cover_image'),
    website: text('website'),
    location: text('location'),
    socialLinks: jsonb('social_links').$type<Record<string, string>>(), // { twitter, bluesky, ... }
    metaTitle: varchar('meta_title', { length: 300 }),
    metaDescription: varchar('meta_description', { length: 500 }),
    createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
    updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
  },
  (table) => [
    foreignKey({
      name: 'fk_author_profiles_user_site',
      columns: [table.siteId, table.userId],
      foreignColumns: [users.siteId, users.id],
    }).onDelete('cascade'),
    index('idx_author_profiles_site').on(table.siteId),
  ]
);

/**
 * Member-specific data. Created when a user signs up as a member
 * (or when an admin subscribes to their own site's content).
 *
 * A user can have both an author_profiles row AND a member_profiles row —
 * an admin who publishes content and also has a paid subscription.
 * A pure admin (site manager, never publishes, no subscription) has neither.
 */
export const memberProfiles = pgTable(
  'member_profiles',
  {
    userId: uuid('user_id').primaryKey(),
    siteId: uuid('site_id')
      .notNull()
      .references(() => sites.id, { onDelete: 'cascade' }),
    status: text('status', { enum: ['free', 'paid', 'comped'] })
      .notNull()
      .default('free'),
    stripeCustomerId: text('stripe_customer_id'),
    newsletterPreferences: jsonb('newsletter_preferences'),
    createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
  },
  (table) => [
    foreignKey({
      name: 'fk_member_profiles_user_site',
      columns: [table.siteId, table.userId],
      foreignColumns: [users.siteId, users.id],
    }).onDelete('cascade'),
    index('idx_member_profiles_site').on(table.siteId),
  ]
);

// Posts reference users, not staff. Since posts carry siteId, author linkage
// must also use composite same-site enforcement.
export const posts = pgTable(
  'posts',
  {
    // ...
    siteId: uuid('site_id')
      .notNull()
      .references(() => sites.id, { onDelete: 'cascade' }),
    authorId: uuid('author_id').notNull(),
    // ...
  },
  (table) => [
    foreignKey({
      name: 'fk_posts_author_site',
      columns: [table.siteId, table.authorId],
      foreignColumns: [users.siteId, users.id],
    }),
  ]
);

Why siteId on extension tables: Both author_profiles and member_profiles include siteId. A single-column FK to users.id does not enforce same-site linkage; tables that carry siteId must use composite (site_id, user_fk) references to users(site_id, id). Keeping siteId on these tables is intentional — RLS policies need a direct siteId column to filter on, and it enables efficient queries that filter by site without joining through users.

Tenant-consistency invariant: Every table that carries siteId and one or more FKs to users must enforce that each referenced user belongs to the same site. The migration must use composite foreign keys — for example (site_id, user_id) REFERENCES users(site_id, id) and (site_id, granted_by) REFERENCES users(site_id, id) — so cross-tenant references are rejected at the database level, not just by application logic. This requires a composite unique constraint on users(site_id, id) to serve as the FK target. The same pattern applies across ADR-013 for createdBy, userId, actorUserId, targetUserId, and approvedBy.

Why socialLinks is JSONB: Ghost has added 9+ individual social columns over time (twitter, facebook, threads, bluesky, mastodon, tiktok, youtube, instagram, linkedin), each requiring a migration. A JSONB field with a typed interface is extensible without migrations. Social links are never queried independently — they're always fetched as part of a profile — so the indexing trade-off is acceptable.

Profile model: who gets what

User typeusers rowauthor_profilesmember_profiles
Admin who publishes and subscribesyesyesyes
Admin who publishes (no subscription)yesyesno
Admin who manages site (never publishes)yesnono
Editor / Authoryesyesno
Paid member (subscriber)yesnoyes
Free member (reader)yesnoyes
Member who becomes a contributoryesyes (created on promotion)yes

The users table's name, slug, and avatar fields are the minimal public identity — sufficient for admin panel display, audit log attribution, comment authorship, and basic author cards. The extension tables add domain-specific data created on demand.

Better Auth integration

In the unified model, Better Auth maps userusers directly — simpler than the current userstaff mapping. Better Auth's account table (which stores OAuth provider tokens and password hashes) and session table continue to reference the users table via userId FK. The database hooks that propagate siteId from users to sessions/accounts (ADR-005) work identically.

Passwords are not on the users table. Better Auth stores password hashes in the account table (one row per auth provider per user, with providerId: "credential" for email+password). This is unchanged from the current architecture — the ADR-005 pattern applies directly.

Auth flow differentiation

The unified model supports different auth flows for different users through Better Auth's account-per-provider architecture, not table separation:

User typeAuth methodsBetter Auth state
Admin (password)Email + passwordusers row + account row (providerId: "credential")
Admin (password + SSO)Email + password + Googleusers row + 2 account rows (credential + google)
Member (magic link)Magic link onlyusers row + no account rows

A magic-link-only user has zero rows in the account table — they physically cannot use password auth because no credential exists to verify against. An admin has one or more account rows depending on their configured auth methods.

Configuration:

const auth = betterAuth({
  emailAndPassword: {
    enabled: true,
    disableSignUp: true, // No self-service password registration
  },
  socialProviders: {
    google: {
      clientId: env.GOOGLE_CLIENT_ID,
      clientSecret: env.GOOGLE_CLIENT_SECRET,
      disableImplicitSignUp: true, // SSO only links to existing users
    },
  },
  plugins: [
    magicLink({
      disableSignUp: false, // Members can self-register via magic link
      sendMagicLink: async ({ email, url }) => {
        /* send email */
      },
    }),
  ],
});

This means:

  • Admins are created via invitation (seed script or admin invite flow), which creates a users row and a credential account row. They can optionally link Google SSO.
  • Members self-register via magic link, which creates a users row and a member_profiles row. No account row is created, so password auth is impossible.
  • Promotion from member to contributor: grant content.create permission. The user can continue using magic link auth, or an admin can set a password for them (creating a credential account row). No table migration, no new identity.

SSO account linking is disabled for implicit sign-up (disableImplicitSignUp: true) to prevent a member's Google account from auto-linking and gaining access to the admin panel. SSO is only available to users who already have a users row created through the invitation flow.

For organisations that want all SSO users from a specific domain to receive default access (e.g. everyone at @company.com gets editor permissions), disableImplicitSignUp can be set to false with a domain-check hook that validates the email domain and auto-grants a permission preset via PermissionService. This is a per-site configuration choice — it doesn't change the architecture, just the provisioning policy.


Decision

Option B: Unified user model.

The split's security value is real but narrow — it prevents one attack vector that the defense-in-depth stack also prevents through Zod validation, PermissionService isolation, RLS policies, and ESLint guard rules. Its architectural cost is ongoing and compounds as the product evolves.

Enforcement mechanisms (non-negotiable)

These must ship with or before the unified model:

  1. ESLint guard rule (toast/no-direct-permission-writes) — CI-blocking.
  2. Architectural test — all non-public routes declare requirePermission(). CI-blocking.
  3. RLS policy on user_permissions — database-level enforcement.
  4. Better Auth cookie auditHttpOnly, SameSite=Strict, admin subdomain scoping.

Consequences

Positive:

  • Shadow member accounts eliminated; impersonation unified into a single mechanism (see ADR: Access Model)
  • Graduated permission grants replace cross-table promotion complexity
  • Architecture supports community product evolution without rework
  • Better Auth integration simplified
  • No owner role eliminates single-owner transfer, lockout recovery, and special-case code

Negative / risks:

  • PermissionService discipline must be maintained actively and permanently
  • A unified model with poor discipline has no structural floor
  • CSP implementation is non-trivial
  • Migration from Ghost's two-table model to unified is a significant future undertaking

References

  • ADR-013: Access Model (permissions, tokens, roles, scopes, actors)
  • ADR-004: Service and Repository Architecture
  • ADR-005: Authentication Architecture
  • ADR-006: Linting Strategy (ESLint guard rule pattern)
  • ADR-007: Event System (audit logging)

On this page