Toast
ContributorDecisions

ADR: Access Model

ADR: Access Model

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


Context

The unified user model ADR replaces Ghost's staff/member split with a single users table. That decision creates a dependency: without separate tables enforcing who can do what, the access model becomes load-bearing infrastructure. This ADR defines that model — how permissions work, how tokens work, how roles are represented, how impersonation works, and how the system tracks who did what.

Ghost's current access model has three problems this ADR addresses:

1. Roles without granularity. Ghost has four roles: owner, admin, editor, author. Permissions are hardcoded per role. You can't give an editor the ability to manage members without making them an admin. You can't restrict an admin from accessing billing without inventing a new role.

2. No scoped API tokens. Ghost's personal access tokens carry the full permissions of the user who created them. Ghost's "integrations" (site-level tokens) have similarly broad access. There's no way to create a token that can publish content but not delete it, or read members but not export them.

3. The owner role. Ghost requires exactly one owner per site. This creates fragile single-point-of-failure scenarios: what happens when the owner leaves the company, loses their password, or dies? Ghost's support team has handled all three. The owner concept conflates "who pays for the site" (a billing relationship) with "who has the most permissions" (an access control concern).

This ADR also addresses two further concerns:

4. Agent attribution. AI agents acting on behalf of users need to be distinguishable from the users themselves in audit logs. The access model must support "Hannah's writing agent published this" as distinct from "Hannah published this."

5. Impersonation. Admins need to become any user to debug their experience. Ghost(Pro) support needs to become any user on any site. Ghost currently handles this through separate member impersonation (admin→member) and staff impersonation (support→staff→member, a two-hop flow). The unified model — where a user is a user regardless of permissions — eliminates the type boundary that forced the two-hop pattern.


Decision Drivers

  • Align with industry standards (NIST RBAC, OAuth, Better Auth) rather than inventing terminology
  • Support AI agents as first-class actors without new entity types
  • Eliminate the owner role's complexity without losing its legitimate functions
  • Unify impersonation into a single mechanism that works for every user type
  • Design for self-hosting: avoid dependencies that only work in hosted environments

Terminology

This ADR establishes vocabulary that applies across Toast. These terms were chosen after surveying Better Auth, Auth0, Strapi, n8n, Kubernetes, and OAuth 2.0:

  • Permission — what a user can do. Granular, additive. Example: content.publish. This is the user-level access control term.
  • Role — a named preset of permissions. Convenience for humans, not a database entity. When you make someone an "editor," you grant them the editor permission set.
  • Scope — a permission subset on a specific token. Uses the same vocabulary as permissions. Example: a token with scope content.create can only create content, even if the user who created it can also publish and delete.
  • Capability — reserved for install-level system features per ADR-010. Example: "this Ghost install has the email-sending capability enabled." Not used for user access control.

The capability/permission split avoids the WordPress problem, where "capability" means user-level access control and causes documented confusion with install-level features. Better Auth uses hasPermission() for user access checks, which aligns with our terminology.


The Three-Layer Model

Layer 1: Permissions

Permissions are granular actions a user can perform. They are additive — a user starts with no permissions and gains them through grants. Each permission is a dotted string in the format domain.action.

export const permissionEnum = pgEnum('permission', [
  // Content
  'content.create',
  'content.edit_own',
  'content.edit_all',
  'content.publish',
  'content.delete',

  // Members
  'members.view',
  'members.manage',

  // Site configuration
  'site.settings',
  'site.billing',
  'site.delete',

  // Administration
  'admin.access', // can access the admin panel
  'admin.manage_staff', // can invite/remove users, grant/revoke permissions

  // Impersonation
  'users.impersonate', // can impersonate any user on this site
]);

Permissions are stored in user_permissions — one row per user per permission. The permission enum is a PostgreSQL enum, which means invalid permissions are rejected at the database level. Adding new permissions requires a migration, which is intentional — it forces deliberate consideration.

A user with no rows in user_permissions is a member (reader/subscriber). They can access the public site, manage their own profile and subscription, and nothing else. Their member-specific data (subscription status, Stripe customer ID, newsletter preferences) lives in the member_profiles table.

Layer 2: Roles (Code Constants)

Roles are named presets of permissions defined in application code, not stored in the database. They exist to answer the question "what permissions should an editor get?" without requiring an admin to check twelve boxes.

// permissions/presets.ts

export const ROLE_PRESETS = {
  admin: [
    'admin.access',
    'admin.manage_staff',
    'site.settings',
    'site.billing',
    'site.delete',
    'content.create',
    'content.edit_own',
    'content.edit_all',
    'content.publish',
    'content.delete',
    'members.view',
    'members.manage',
    'users.impersonate',
  ],
  editor: [
    'admin.access',
    'content.create',
    'content.edit_own',
    'content.edit_all',
    'content.publish',
    'content.delete',
    'members.view',
  ],
  author: ['admin.access', 'content.create', 'content.edit_own'],
} as const satisfies Record<string, Permission[]>;

When an admin invites someone as an "editor," the system grants them the editor permission set. The word "editor" appears in the UI and invitation flow but is not stored in the database — the database stores individual permission grants.

This means adding new permissions to a preset (e.g., adding content.schedule to the editor preset) does not retroactively apply to existing users. This is intentional — it prevents silent permission escalation. If a new permission matters, it should be granted explicitly.

Roles exist for convenience, not enforcement. An admin can always grant individual permissions outside any preset. A user might have all editor permissions plus members.manage — they don't need a new role for this; they just have an extra permission.

Layer 3: Scopes (Token Restriction)

Scopes restrict what a specific API token can do. They use the same vocabulary as permissions. A token's effective access is determined by its type:

User token: effective access = intersection of user's permissions AND token's scopes. Site token: effective access = the token's scopes directly (no user to intersect with).

If a user has [content.create, content.publish, content.delete] and creates a token with scopes [content.create, content.publish], the token can create and publish but not delete — even though the user can.

If the user later loses content.publish, the token also loses it — the intersection shrinks. If the user is removed from the site, the token dies with them.


Token Architecture

Two fundamentally different token types exist because they serve different lifecycle and accountability needs.

User Tokens

Act on behalf of a specific user. The token's effective access cannot exceed the user's own permissions. If the user is removed, the token is revoked. If the user's permissions change, the token's effective access changes.

Use cases: AI agents (Claude writing on Hannah's behalf), CLI tools, personal automation scripts, mobile apps.

Ghost has these today (personal access tokens) but without scopes — a Ghost personal token carries the full permissions of the user's role. Toast adds scoping.

Site Tokens

Act on behalf of the site itself, not any individual user. Not tied to any user's lifecycle. The token's scopes ARE its permissions — there's no user to intersect with.

Use cases: Zapier/n8n integrations, webhook receivers, CI/CD pipelines, monitoring systems, third-party services that should survive staff turnover.

Ghost has these today as "integrations." They're the right concept, just missing scoping.

The critical distinction: if an employee who set up the Zapier integration leaves, a site token keeps working. A user token would die with their account. Choosing the wrong token type creates operational fragility.

Schema

Note on ID types: Schema sketches in this ADR use uuid to indicate intent. See ADR-012 for the note on Better Auth's string ID conventions — the actual column type will be resolved at implementation time.

export const apiTokens = pgTable(
  'api_tokens',
  {
    id: uuid('id').defaultRandom().primaryKey(),
    siteId: uuid('site_id')
      .notNull()
      .references(() => sites.id, { onDelete: 'cascade' }),
    name: text('name').notNull(), // "claude-writing-agent", "zapier-sync", etc.
    type: text('type', { enum: ['user_token', 'site_token'] }).notNull(),

    secretHash: text('secret_hash').notNull(), // bcrypt hash of the token value

    // Who created this token (audit trail — always set)
    createdBy: uuid('created_by').notNull(),

    // Which user this token acts as (NULL for site tokens)
    userId: uuid('user_id'),

    // Permission subset this token is allowed to exercise
    scopes: jsonb('scopes').$type<string[]>().notNull(),

    expiresAt: timestamp('expires_at', { withTimezone: true }),
    lastUsedAt: timestamp('last_used_at', { withTimezone: true }),
    revokedAt: timestamp('revoked_at', { withTimezone: true }),
    createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
  },
  (table) => [
    index('idx_api_tokens_site').on(table.siteId),
    index('idx_api_tokens_user').on(table.userId),
    foreignKey({
      name: 'fk_api_tokens_created_by_site',
      columns: [table.siteId, table.createdBy],
      foreignColumns: [users.siteId, users.id],
    }),
    foreignKey({
      name: 'fk_api_tokens_user_site',
      columns: [table.siteId, table.userId],
      foreignColumns: [users.siteId, users.id],
    }).onDelete('cascade'),
  ]
);

Tenant-consistency: Tables that reference users alongside a siteId (api_tokens, impersonation_grants) must use composite foreign keys for every user reference column — createdBy, userId, actorUserId, targetUserId, and approvedBy — so same-site linkage is enforced at the database level. See ADR-012's tenant-consistency invariant for the shared pattern.

Scope validation: The scopes column uses JSONB rather than a database enum array because scope sets are token-specific subsets of the permission vocabulary. The TokenService validates that every scope value exists in the permissionEnum at token creation time. A CHECK constraint using a database function is a reasonable migration-time hardening step but not architecturally required — the single-writer pattern (TokenService) is the primary enforcement.

Permission resolution at request time:

function resolveTokenPermissions(token: ApiToken, user: User | null): Permission[] {
  if (token.type === 'site_token') {
    // Site token: scopes ARE the permissions
    return token.scopes;
  }

  // User token: intersection of user's permissions and token's scopes
  const userPermissions = getUserPermissions(user!.id);
  return token.scopes.filter((scope) => userPermissions.includes(scope));
}

Token Management

Site tokens are managed by anyone with appropriate permissions (e.g., site.settings). They're a site resource, not owned by their creator — if the person who created a Zapier integration leaves, another admin can manage or revoke it.

User tokens are managed by the user who owns them. An admin cannot create tokens on behalf of other users (out of scope for the experiment; schema-ready via createdBy vs userId separation if needed later).


Actor Model and Agent Attribution

Every state-changing operation in Toast is attributed to an actor. The actor model must distinguish between a human in a browser, a user's AI agent, a site-level automation, an impersonation session, and the system itself.

type Actor =
  | { type: 'user'; userId: string }
  | { type: 'token'; tokenId: string; userId: string } // user token
  | { type: 'token'; tokenId: string; userId: null } // site token
  | { type: 'impersonation'; realUserId: string; effectiveUserId: string; grantId: string | null }
  | { type: 'system' };

For token-based actors, the token's name field provides agent identity. When Hannah creates a token named "claude-writing-agent" and a separate one named "seo-optimizer," the audit log shows:

  • "Hannah published this" → { type: 'user', userId: '...' }
  • "Hannah's claude-writing-agent published this" → { type: 'token', tokenId: '...', userId: '...' }
  • "Hannah's seo-optimizer published this" → { type: 'token', tokenId: '...', userId: '...' }
  • "support+sarah acting as jane@example.com" → { type: 'impersonation', realUserId: '...', effectiveUserId: '...', grantId: '...' }

For the experiment, token = agent identity. This is sufficient and avoids premature abstraction. If Toast later needs agents as first-class entities (with descriptions, behaviour tracking, multiple tokens per agent), the schema supports it — add an agents table and an agentId column to api_tokens. The audit log's token reference resolves to the agent through the token. Nothing in the current design prevents this evolution.

Why this matters now

The mechanical pattern of "token acts on behalf of user" isn't new — it's how every API token system works. What's new is the scale of autonomous action. When a human runs a script via a token, the human is accountable and the script is deterministic. When an AI agent operates via a token, the agent makes its own decisions about what to do and when. The audit trail becomes the only way to understand what happened and why.

The access model doesn't need new abstractions for this — it needs the existing abstractions (tokens, scopes, attribution) to be complete and trustworthy. That's why scopes matter (the agent can only do what its token allows), names matter (the audit log identifies which agent), and user-token lifecycle binding matters (if the human leaves, their agents stop).

Transition from existing actor types

The current event system (ADR-007) and audit log schema use actorTypeEnum = pgEnum('actor_type', ['staff', 'api_key', 'automation', 'system']). This ADR replaces that with the richer Actor union type above. The mapping:

CurrentNewNotes
staff{ type: 'user' }Renamed to match unified model terminology
api_key{ type: 'token' }Now distinguishes user tokens from site tokens via userId
automation{ type: 'token', userId: null }Site-level automations use site tokens
system{ type: 'system' }Unchanged
(new){ type: 'impersonation' }Dual-identity attribution

The actorTypeEnum in the database schema should be updated to ['user', 'token', 'impersonation', 'system']. Existing audit log rows with staff or api_key values will need a data migration. The audit log's actorId column (currently a single text field) should be extended to support the richer attribution — either as JSONB or as multiple columns (actorId, tokenId, effectiveUserId). This is an implementation detail to resolve alongside the schema migration.


Impersonation

Impersonation lets one user temporarily become another user — seeing what they see, experiencing the application as they experience it. This is a critical operational tool for debugging member issues, and in Ghost(Pro), for support access.

Why the unified model simplifies this

In Ghost, impersonation has a type boundary problem. Staff and members are different entity types, so there are two separate mechanisms: member impersonation (staff→member) and staff impersonation (support→staff). If support needs to debug a member's issue, they must first impersonate a staff user, then impersonate the member from there — a two-hop flow.

In Toast, a user is a user. Some have admin permissions, some have no permissions, most are somewhere in between. Impersonation is one mechanism: become this user. It works regardless of what permissions the target has.

  • Admin wants to debug a member's experience → impersonate that member directly
  • Support wants to debug a member's content access → impersonate that member directly
  • Support wants to debug an admin's editor bug → impersonate that admin directly

One step. No hops. No type boundaries.

How impersonation works

The impersonator is authenticated as themselves. They trigger impersonation (via the admin UI or platform tooling). The system:

  1. Checks the user is authorised to impersonate the target (see authorisation paths below)
  2. Modifies the session to carry dual identity:
// Session shape during impersonation
{
  userId: 'sarah-support-id',       // real identity — never changes
  effectiveUserId: 'jane-member-id', // who the application treats them as
  impersonationGrantId: 'grant-id',  // null for permission-based impersonation
}
  1. All middleware and services resolve the effective user for permission checks, content resolution, and UI rendering. The impersonator inherits the target's permissions — not their own. If support impersonates a member with no permissions, they see the public site as that member. If support impersonates an admin, they see the admin panel with that admin's permissions.

  2. A banner in the UI shows "Viewing as jane@example.com" with a "Stop impersonating" button that clears effectiveUserId from the session, returning to the impersonator's own identity. If the session expires or the browser crashes during impersonation, the next authentication restores the real user's own session — impersonation state does not persist across re-authentication.

  3. Every action during impersonation is logged with both identities:

"support+sarah@ghost.org updated newsletter preferences as jane@example.com"
"admin@coolsite.com viewed portal as jane@example.com"

What the impersonator can do

Full impersonation — the impersonator can see and do everything the target user can do. If an admin is debugging a member's broken newsletter preference, they can fix it directly while impersonating. If support is reproducing an editor bug for an admin user, they can interact with the editor as that admin would.

The audit trail is the accountability layer, not access restrictions on what you can do while impersonating. Every action is attributed to both the real and effective identity.

Two authorisation paths

Permission-based (site admins): a user with users.impersonate on a site can impersonate any user on that site. No pre-created grant needed. The admin clicks "Impersonate" on a user profile in the admin panel and immediately gets a session as that user. This is included in the admin role preset.

An admin can impersonate anyone — other admins, editors, members, anyone. No hierarchy checks, no restrictions on targeting users with higher permissions. The audit log makes all impersonation visible.

Grant-based (Ghost(Pro) support): for people who don't have permissions on the site. The platform tool provisions a temporary support user (support+sarah@ghost.org) on the target site, along with an impersonation grant that names the specific user they can impersonate. Time-limited, reason-tracked, revocable.

The platform tool handles this by writing directly to the site's database — there is no public API endpoint for creating impersonation grants. This is the same trust boundary that exists today (Ghost(Pro) ops have DB access). The difference is that instead of raw SQL, structured tooling creates proper audit trails.

Grant-based impersonation flow (Ghost(Pro) support)

  1. Support person (Sarah) is working ticket #12345 where a member (jane@example.com) on coolsite.ghost.io reports an issue.

  2. Platform tool provisions access:

    • Creates a users row with email support+sarah@ghost.org (identifies the individual for audit purposes without exposing personal email)
    • Creates user_permissions rows with a support preset and expires_at (e.g., admin.access, members.view, enough to navigate the admin panel)
    • Creates an impersonation_grants row authorising support+sarah to impersonate jane@example.com, with reason "ticket #12345" and a 24-hour expiry
  3. Platform tool triggers a magic link to support+sarah@ghost.org. Sarah clicks it, gets a normal Better Auth session on the target site.

  4. Sarah lands in the admin panel authenticated as herself. She can look up Jane's member record, check subscription status, review settings — all through normal permission checks.

  5. Sarah clicks "Impersonate" on Jane's profile. The system checks the impersonation grant, finds a valid unexpired grant, and switches the session to dual-identity mode. Sarah now sees the site as Jane — Jane's tier, Jane's content access, Jane's portal experience.

  6. Sarah identifies and fixes the issue (or gathers the information she needs). She clicks "Stop impersonating" and returns to her admin view.

  7. Grant and permissions expire after 24 hours. Cleanup job removes expired rows. The audit log preserves the complete trail of everything Sarah did, attributed to both identities.

Schema

export const impersonationGrants = pgTable(
  'impersonation_grants',
  {
    id: uuid('id').defaultRandom().primaryKey(),
    siteId: uuid('site_id')
      .notNull()
      .references(() => sites.id, { onDelete: 'cascade' }),

    // Who can impersonate
    actorUserId: uuid('actor_user_id').notNull(),

    // Who they can impersonate
    targetUserId: uuid('target_user_id').notNull(),

    // Audit and policy
    reason: text('reason').notNull(), // "ticket #12345"
    approvedBy: uuid('approved_by'), // site admin, or null for platform-provisioned

    expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
    revokedAt: timestamp('revoked_at', { withTimezone: true }),
    createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
  },
  (table) => [
    foreignKey({
      name: 'fk_impersonation_grants_actor_site',
      columns: [table.siteId, table.actorUserId],
      foreignColumns: [users.siteId, users.id],
    }).onDelete('cascade'),
    foreignKey({
      name: 'fk_impersonation_grants_target_site',
      columns: [table.siteId, table.targetUserId],
      foreignColumns: [users.siteId, users.id],
    }).onDelete('cascade'),
    foreignKey({
      name: 'fk_impersonation_grants_approved_by_site',
      columns: [table.siteId, table.approvedBy],
      foreignColumns: [users.siteId, users.id],
    }).onDelete('set null'),
  ]
);

Permission expiry

The user_permissions table gains an expires_at column. Normal user permissions have expires_at = NULL (never expires). Support permissions have a concrete timestamp.

export const userPermissions = pgTable('user_permissions', {
  // ... existing columns ...
  expiresAt: timestamp('expires_at', { withTimezone: true }), // NULL = permanent
});

Permission resolution filters on expiry at query time:

const permissions = await db
  .select()
  .from(userPermissions)
  .where(
    and(
      eq(userPermissions.userId, userId),
      eq(userPermissions.siteId, siteId),
      or(isNull(userPermissions.expiresAt), gt(userPermissions.expiresAt, new Date()))
    )
  );

When the timestamp passes, the next request returns 403. No background job needed for the security boundary — expired permissions are ignored at query time. A separate cleanup job deletes expired rows for hygiene.

This is useful beyond support: temporary editor access for a guest author, trial admin access, time-limited contributor grants.

Self-hosted sites

Impersonation grants are a Ghost(Pro) concept — the platform tool is the only thing that creates support users and grants. Self-hosted site owners don't need this; they have database access. If a self-hosted admin wants to let someone support their site, they invite them as a normal user with limited permissions and remove them when done. The users.impersonate permission and the admin panel's impersonation UI work identically on self-hosted and Ghost(Pro) sites.

Site owner visibility

The support user (support+sarah@ghost.org) appears in the site's user list — they're a real user, not hidden. The audit log shows everything they did, including impersonation sessions. The site owner can see "support+sarah@ghost.org had access from Feb 26 10:00 to Feb 27 10:00, impersonated jane@example.com, and viewed these three pages." The site owner can revoke the support user's permissions or impersonation grant early.


Admin Role Design: No Owner

Ghost's owner role — exactly one per site, can transfer ownership, can't be removed by other admins — creates well-documented operational pain. Toast replaces it with a simpler model.

The problem with "owner"

The owner role conflates two unrelated concepts:

"Who pays for the site" is a billing relationship with the hosting platform. It lives at the platform level (Ghost(Pro), Stripe subscription), not the site level. The person paying should be able to regain access through the platform's billing relationship, not through a special database role.

"Who has the most permissions" is an access control question. Multiple people should be able to have full permissions. Restricting this to one person creates a single point of failure.

When these are the same concept, you get edge cases Ghost's support team has handled repeatedly: the owner forgot their password and their 2FA device is lost. The owner left the company. The owner died. Each requires manual database intervention because the system has no legitimate path to resolve a single-owner lockout.

How other platforms handle this

GitHub: Multiple "owners" (confusing name, but the concept is plural). Any owner can do everything including manage other owners. Can't leave yourself as the last owner. No singular owner.

Discourse: admin is a boolean flag on the users table. Any admin can make or revoke other admins. The only constraint: can't revoke the last admin.

Slack: Singular "primary owner" with transfer capability. Creates exactly the lockout problems described above. Slack's help docs include "we may be able to help, but can't guarantee."

Stripe: Singular "account owner" who must explicitly transfer. Same fragility.

The platforms that chose multiple admins with "can't remove the last one" report fewer support escalations and simpler code.

Toast's approach

No owner role. Multiple users can have the full admin permission set. One application-level invariant replaces the entire owner concept:

Cannot remove the last user with admin.manage_staff.

This is enforced at the PermissionService level: before revoking admin.manage_staff from a user, count how many other users on the site have it. If the answer is zero, reject the operation. This is a three-line check, not a role hierarchy.

Step-up authentication for dangerous actions. Granting admin.manage_staff, changing billing settings (site.billing), and deleting the site (site.delete) require recent password/2FA confirmation. This protects against session hijacking — even if someone gains an admin session, they can't perform destructive actions without proving identity.

Billing lives at the platform level. For Ghost(Pro), the billing relationship is between a person and the hosting platform, not between a person and a site role. The person paying can regain access through the platform's billing flow. For self-hosted sites, the person running the server has database root access — they are the ultimate authority regardless of application-level roles.

What this eliminates

  • Ownership transfer flow (including the UI, the API endpoint, the email confirmation, the edge cases)
  • "What happens if the owner..." support playbook
  • Special-case code throughout the admin that treats the owner differently from other admins
  • The owner enum value in the role system and all conditionals that check for it

Permission Resolution Summary

The complete resolution chain at request time:

Request arrives
  → Identify actor (session cookie OR API token)

  → If session with impersonation:
      → Resolve effective user, load their permissions (filtered by expiry)
      → Log actor as impersonation with both real and effective identity

  → If session (normal):
      → Resolve user, load permissions (filtered by expiry)

  → If token:
      → If user_token: load user permissions, intersect with token scopes
      → If site_token: token scopes ARE the permissions

  → Check: does actor have the permission required by this route?
  → If yes: proceed, log actor attribution
  → If no: 403 Forbidden

Decision

Adopt the three-layer access model (permissions, roles as code presets, scopes on tokens), the two-type token architecture (user tokens and site tokens), the unified impersonation model (permission-based for site admins, grant-based for platform support), the actor attribution model, and the no-owner admin design.

Key constraints

  1. Permissions as the vocabulary. User access checks use hasPermission(), token restriction uses scopes from the same vocabulary, and the permission enum is the single source of truth for what actions exist.
  2. No owner role. The only invariant is "can't remove the last admin.manage_staff." Step-up auth for dangerous actions.
  3. Token type determines resolution. User tokens intersect with their user's permissions. Site tokens stand alone.
  4. Impersonation is one mechanism. users.impersonate permission for site admins, impersonation_grants for cross-boundary access. Dual-identity sessions with full audit attribution. No type boundaries, no hops.
  5. Actor attribution is mandatory. Every state-changing operation logs the actor with enough detail to distinguish human, agent, automation, and impersonation.

Consequences

Positive:

  • Granular access control replaces Ghost's four rigid roles
  • Scoped tokens enable secure AI agent delegation
  • No owner role eliminates an entire class of support escalations
  • Unified impersonation replaces the two-hop staff→member flow with a single mechanism
  • Same permission vocabulary across UI, API, and tokens reduces cognitive overhead
  • Agent attribution future-proofs audit logging for autonomous AI operations
  • Permission expiry enables temporary access patterns (support, guest authors, trials)

Negative / risks:

  • More granular permissions means more decisions when inviting a user (mitigated by role presets)
  • Permission enum growth requires migrations (intentional — forces deliberate consideration)
  • Two token types adds complexity to token creation UI (mitigated by clear use-case guidance)
  • Full impersonation (not read-only) means an impersonator can take any action the target can — the audit trail is the sole accountability mechanism
  • Ghost(Pro) platform tooling must be built to provision support users and grants

What changes in existing code:

  • requireAuth() middleware extended with requirePermission() — affects all route definitions
  • Session model extended to carry dual identity during impersonation
  • Audit log actorTypeEnum updated from ['staff', 'api_key', 'automation', 'system'] to ['user', 'token', 'impersonation', 'system'] (see transition table in Actor Model section)
  • Better Auth account and session tables re-pointed from staff to users (FK changes)
  • Token creation and validation endpoints added to the API
  • Admin UI needs permission management interface (grant/revoke, not just role assignment)
  • Admin UI needs impersonation trigger on user profiles and "stop impersonating" banner

References

  • ADR-012: Unified User Model (users table, member profiles, Better Auth integration)
  • ADR-004: Service and Repository Architecture (PermissionService follows this pattern)
  • ADR-005: Authentication Architecture (Better Auth, session management)
  • ADR-006: Linting Strategy (ESLint guard rule for permission writes)
  • ADR-007: Event System (audit logging, actor attribution)
  • ADR-010: Capabilities System (install-level features — distinct from user permissions)
  • Better Auth documentation: hasPermission() API
  • NIST RBAC model (ANSI/INCITS 359-2004)
  • OAuth 2.0 scopes (RFC 6749)

On this page