How to Manage Open-SEO Users and Permissions: A Complete Implementation Guide
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 (SQLite) and 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:
// 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:
// 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.
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:
{
userId: string,
userEmail: string,
organizationId: string,
scopes: string[]
}
This AuthInfo object populates downstream permission checks. The entry point in 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:
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:
- Inserts a row into the
organizationtable - Creates a corresponding
memberrow with roleowner
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:
// 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:
- Validates the invitation token
- Creates a
memberrow with the invited role - Deletes the
invitationrow
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:
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
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
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
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):
- Update the schema: Modify the
rolecolumn default insrc/db/better-auth-schema.ts - Validate inputs: Add the new role to Zod schemas that narrow trusted input
- 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 |
Defines user, organization, member (with role), and invitation tables |
src/db/pg/better-auth-schema.ts |
PostgreSQL counterpart of the authentication schema |
src/middleware/ensure-user/resolve.ts |
Core logic extracting authenticated user from request headers |
src/server.ts |
Server entry point injecting auth context into every request |
src/serverFunctions/middleware.ts |
Enforces project-organization boundary validation |
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.tsresolves authentication context for every request, handling both local and hosted deployments - Cross-tenant protection requires verifying
project.organizationIdmatchesauth.organizationIdbefore 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 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 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, 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 file demonstrates this pattern with explicit thrown errors when the organization boundary check fails.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →