# How to Manage Open-SEO Users and Permissions: A Complete Implementation Guide

> Learn to manage Open-SEO users and permissions effectively with Drizzle ORM and ensure-user middleware. Implement robust role-based access control for your application.

- Repository: [Every App/open-seo](https://github.com/every-app/open-seo)
- Tags: how-to-guide
- Published: 2026-08-02

---

**A.** Open-SEO uses **Drizzle ORM** with a relational model of **User, Organization, Member, and Invitation** tables, enforced by the `ensure-user` middleware that resolves authentication context from request headers and validates role-based access before every operation.

Understanding how to manage Open-SEO users and permissions requires familiarity with its multi-tenant architecture. The codebase implements a lightweight but robust authorization system where organizations serve as logical tenants, members carry role-based privileges, and middleware intercepts every request to enforce security boundaries. This guide walks through the core data model, permission enforcement mechanisms, and practical code patterns for common user management workflows.

---

## Core Data Model for Users and Permissions

Open-SEO stores all authentication and authorization data in four interconnected tables defined in **[`src/db/better-auth-schema.ts`](https://github.com/every-app/open-seo/blob/main/src/db/better-auth-schema.ts)** (SQLite) and **[`src/db/pg/better-auth-schema.ts`](https://github.com/every-app/open-seo/blob/main/src/db/pg/better-auth-schema.ts)** (PostgreSQL).

### User Table

The **User** table contains primary account information including `id`, `email`, `name`, and hashed password. Users are created during sign-up or when initiating the hosted/local-no-auth authentication flow.

### Organization Table

The **Organization** table represents the logical tenant boundary. Every project belongs to exactly one organization, and users gain access by becoming members of that organization.

### Member Table

The **Member** table is the critical junction linking users to organizations with assigned roles:

```ts
// From src/db/better-auth-schema.ts
role: text("role").default("member").notNull(),

```

This `role` column drives all permission checks. The default value is `"member"`, while `"owner"` grants elevated privileges for billing management and member invitations.

### Invitation Table

The **Invitation** table tracks pending email invitations:

```ts
// From src/db/better-auth-schema.ts
email: text("email").notNull(),
role: text("role"),

```

When accepted, an invitation row transforms into a member row with the specified role.

---

## Permission Enforcement with ensure-user Middleware

All server-side functions begin by resolving the caller's identity through the **ensure-user middleware**, implemented in **[`src/middleware/ensure-user/resolve.ts`](https://github.com/every-app/open-seo/blob/main/src/middleware/ensure-user/resolve.ts)**.

### Authentication Context Resolution

The middleware inspects the `AUTH_MODE` environment variable and handles two deployment scenarios:

- **`local_noauth`**: Returns a local admin context for self-hosted deployments
- **Hosted/SaaS**: Verifies Cloudflare Access tokens or hosted JWTs

The resolved context contains:

```ts
{
  userId: string,
  userEmail: string,
  organizationId: string,
  scopes: string[]
}

```

This `AuthInfo` object populates downstream permission checks. The entry point in **[`src/server.ts`](https://github.com/every-app/open-seo/blob/main/src/server.ts)** runs `resolveUserContextFromHeaders` for every incoming request.

### Project-Level Access Control

Server functions validate organization boundaries before operating on project data. As shown in **[`src/serverFunctions/middleware.ts`](https://github.com/every-app/open-seo/blob/main/src/serverFunctions/middleware.ts)**:

```ts
if (!authenticatedContext.project) {
  throw new Error("Project context missing from authenticated server function");
}

```

---

## Common User and Permission Workflows

### Creating an Organization

When a logged-in user calls `createOrganization`, the server:
1. Inserts a row into the `organization` table
2. Creates a corresponding `member` row with role `owner`

This ensures the creator has full administrative control from inception.

### Inviting Team Members

Invitation endpoints verify the caller's role before proceeding. Only members with `owner` role can create invitations:

```ts
// Verify inviter's role
const inviterMember = await db
  .select()
  .from(member)
  .where(and(eq(member.userId, inviterId), eq(member.organizationId, orgId)));

if (inviterMember[0]?.role !== "owner") {
  throw new Error("Only owners can invite members");
}

```

### Accepting Invitations

The acceptance flow:
1. Validates the invitation token
2. Creates a `member` row with the invited role
3. Deletes the `invitation` row

### Cross-Tenant Protection

Every project operation verifies the `projectId` belongs to the authenticated `organizationId`, preventing unauthorized cross-tenant access.

---

## Practical Code Examples for Managing Open-SEO Users

### Resolving Authentication Context

Use this pattern in every API route that requires user identification:

```ts
import { resolveUserContextFromHeaders } from "@/middleware/ensure-user/resolve";

export async function handler(request: Request) {
  const auth = await resolveUserContextFromHeaders(request.headers);
  // auth now contains userId, userEmail, organizationId, scopes
}

```

### Sending Member Invitations

```ts
import { db } from "@/db";
import { invitation, member } from "@/db/better-auth-schema";

export async function inviteMember({
  orgId,
  inviterId,
  email,
  role = "member",
}: {
  orgId: string;
  inviterId: string;
  email: string;
  role?: string;
}) {
  // Verify inviter's role
  const inviterMember = await db
    .select()
    .from(member)
    .where(and(eq(member.userId, inviterId), eq(member.organizationId, orgId)));

  if (inviterMember[0]?.role !== "owner") {
    throw new Error("Only owners can invite members");
  }

  await db.insert(invitation).values({
    organizationId: orgId,
    inviterId,
    email,
    role,
    createdAt: new Date(),
  });
}

```

### Processing Invitation Acceptance

```ts
import { db } from "@/db";
import { member, invitation } from "@/db/better-auth-schema";

export async function acceptInvitation({
  invitationId,
  userId,
}: { invitationId: string; userId: string }) {
  const inv = await db.select().from(invitation).where(eq(invitation.id, invitationId));

  if (!inv[0]) throw new Error("Invalid invitation");

  await db.insert(member).values({
    userId,
    organizationId: inv[0].organizationId,
    role: inv[0].role ?? "member",
  });

  await db.delete(invitation).where(eq(invitation.id, invitationId));
}

```

### Guarding Project Operations

```ts
import { ensureUser } from "@/middleware/ensure-user";
import { project } from "@/db/project.schema";

export async function createKeywordList({ projectId, ...rest }, request: Request) {
  const auth = await ensureUser(request); // throws 403 if unauthenticated

  // Verify the project belongs to the caller's organization
  const proj = await db.select().from(project).where(eq(project.id, projectId));
  if (proj[0]?.organizationId !== auth.organizationId) {
    throw new Error("Forbidden: project does not belong to your organization");
  }

  // …business logic…
}

```

---

## Extending the Permission System

To add new roles (e.g., `admin`, `editor`):

1. **Update the schema**: Modify the `role` column default in [`src/db/better-auth-schema.ts`](https://github.com/every-app/open-seo/blob/main/src/db/better-auth-schema.ts)
2. **Validate inputs**: Add the new role to Zod schemas that narrow trusted input
3. **Adjust service logic**: Update `if (member.role === "owner")` checks throughout service layers to recognize the new role hierarchy

---

## Key Files for Understanding Open-SEO User Management

| File | Purpose |
|------|---------|
| [`src/db/better-auth-schema.ts`](https://github.com/every-app/open-seo/blob/main/src/db/better-auth-schema.ts) | Defines `user`, `organization`, `member` (with role), and `invitation` tables |
| [`src/db/pg/better-auth-schema.ts`](https://github.com/every-app/open-seo/blob/main/src/db/pg/better-auth-schema.ts) | PostgreSQL counterpart of the authentication schema |
| [`src/middleware/ensure-user/resolve.ts`](https://github.com/every-app/open-seo/blob/main/src/middleware/ensure-user/resolve.ts) | Core logic extracting authenticated user from request headers |
| [`src/server.ts`](https://github.com/every-app/open-seo/blob/main/src/server.ts) | Server entry point injecting auth context into every request |
| [`src/serverFunctions/middleware.ts`](https://github.com/every-app/open-seo/blob/main/src/serverFunctions/middleware.ts) | Enforces project-organization boundary validation |
| [`src/routes/api/organization/invite.ts`](https://github.com/every-app/open-seo/blob/main/src/routes/api/organization/invite.ts) | Real-world endpoint demonstrating permission checks |

---

## Summary

- **Four core tables** (`user`, `organization`, `member`, `invitation`) implement Open-SEO's multi-tenant permission model in Drizzle ORM
- **Role-based access control** defaults to `"member"` with `"owner"` granting administrative privileges
- **ensure-user middleware** in [`src/middleware/ensure-user/resolve.ts`](https://github.com/every-app/open-seo/blob/main/src/middleware/ensure-user/resolve.ts) resolves authentication context for every request, handling both local and hosted deployments
- **Cross-tenant protection** requires verifying `project.organizationId` matches `auth.organizationId` before data operations
- **Invitation lifecycle** creates pending records that convert to member rows upon acceptance, preserving the invited role

---

## Frequently Asked Questions

### How does Open-SEO handle authentication in self-hosted deployments?

Set `AUTH_MODE=local_noauth` to enable the local admin context. The `resolveUserContextFromHeaders` function in [`src/middleware/ensure-user/resolve.ts`](https://github.com/every-app/open-seo/blob/main/src/middleware/ensure-user/resolve.ts) detects this mode and returns a pre-configured administrative context without requiring external identity providers, simplifying single-tenant deployments.

### What permission checks are required before inviting a new organization member?

The inviting user must have an `owner` role in the target organization. The implementation queries the `member` table for the inviter's record and validates `member.role === "owner"` before inserting the invitation row. This check appears in [`src/routes/api/organization/invite.ts`](https://github.com/every-app/open-seo/blob/main/src/routes/api/organization/invite.ts) and related service layers.

### Can I modify the default roles in Open-SEO?

Yes. Update the `role` column default in [`src/db/better-auth-schema.ts`](https://github.com/every-app/open-seo/blob/main/src/db/better-auth-schema.ts), extend any Zod validation schemas that constrain role values, and modify the conditional checks in service layer code that currently test `member.role === "owner"`. The schema supports any string role value, though the codebase currently implements logic primarily for `member` and `owner`.

### How does Open-SEO prevent users from accessing other organizations' projects?

Every project-related server function calls `ensureUser(request)` to obtain the authenticated context, then verifies the requested `projectId` belongs to `auth.organizationId` before executing business logic. The [`src/serverFunctions/middleware.ts`](https://github.com/every-app/open-seo/blob/main/src/serverFunctions/middleware.ts) file demonstrates this pattern with explicit thrown errors when the organization boundary check fails.