feat(admin): added first user admin role assignment and related documentation (#723)

This commit is contained in:
Corentin Thomasset
2026-01-03 16:30:40 +01:00
committed by GitHub
parent d37025cb94
commit 68d848e622
12 changed files with 348 additions and 9 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@papra/docker": patch
---
Auto assign admin role to the first user registering
@@ -0,0 +1,140 @@
---
title: Roles and Administration
slug: guides/roles-administration
description: Understanding platform roles and admin access in Papra.
---
import { Aside } from '@astrojs/starlight/components';
This guide explains the roles and permissions system in Papra, focusing on platform-wide roles and how to manage admin access.
<Aside type="note">
The administration features are available starting from Papra version `26.0.0`.
</Aside>
## Overview
Papra has two separate role systems:
1. **Platform Roles** - System-wide roles like `admin` for managing the entire Papra instance
2. **Organization Roles** - Workspace-level roles like `owner` and `member` for managing organizations
This guide focuses on platform roles and admin access.
## Platform Roles
### Admin Role
The `admin` role provides system-wide administrative privileges. Admins can:
- **User Management**: View all users, their organizations, and activity
- **Analytics**: Access platform-wide usage statistics and metrics
- **Backoffice Access**: Full access to the admin panel at `/admin`
<Aside type="tip">
At the moment, the admin panel is focused on read-only operations for monitoring and analytics. Additional management capabilities (user moderation, deletion, system configuration, etc.) will be added in future releases.
</Aside>
## Accessing the Admin Panel
Users with the `admin` role can access the admin panel by navigating to:
```
https://your-papra-instance.com/admin
```
Or click on the `Admin` button in the navigation bar (visible only to admins).
## First User as Admin
### Overview
For self-hosted instances, Papra can automatically assign the `admin` role to the first user who registers. This simplifies initial setup by ensuring you have admin access from the start.
### How It Works
1. User registers (first person to create an account)
2. Account is created successfully
3. System checks if this is the first user (user count === 1)
4. The `admin` role is assigned to this user
5. User immediately has admin panel access
6. Subsequent users are normal users without admin privileges
### Configuration
The auto-assignment of the admin role to the first user is controlled by the `AUTH_FIRST_USER_AS_ADMIN` environment variable.
It is enabled by default for self-hosted instances, but can be disabled by setting it to `false`:
```bash
AUTH_FIRST_USER_AS_ADMIN=false
```
### Security Considerations
**Race Conditions:**
If multiple users register simultaneously, at most one will receive the admin role. The system checks `userCount === 1` and uses idempotent role assignment to prevent duplicate admin grants.
**Recommended Practice:**
1. Register your admin account first
2. Disable the feature after setup: `AUTH_FIRST_USER_AS_ADMIN=false`
3. Restart the service if you changed the config
## Manual Admin Assignment
For existing installations with already registered users, you can manually assign the `admin` role using the CLI script `script:make-user-admin`.
Run the following command, replacing `<user-email-or-id>` with the email or ID of the user you want to promote to admin:
```bash
pnpm script:make-user-admin <user-email-or-id>
```
In docker, assuming your container is named `papra`, run:
```bash
docker exec -it papra pnpm script:make-user-admin <user-email-or-id>
```
## Admin Panel Features
### User Management
Navigate to `/admin/users` to:
- View all registered users
- Search users by email, name, or ID
- See user organization memberships
- View user roles and permissions
- Monitor user activity
### Analytics
Navigate to `/admin/analytics` to:
- View registration trends
- Monitor document processing stats
- Track system usage metrics
### Organizations
Navigate to `/admin/organizations` to:
- View all organizations
- Monitor organization activity
- See organization member counts
## Troubleshooting
### I registered but don't have admin access
You can manually assign admin using the script. See the [Manual Admin Assignment](#manual-admin-assignment) section.
### How do I check if a user has admin?
In the admin panel, navigate to the user management section and search for the user. Their roles will be listed in their profile.
### Can I have multiple admins?
Yes, you can assign the `admin` role to multiple users.
## Related Documentation
- [Configuration Guide](/self-hosting/configuration)
- [Docker Setup](/self-hosting/using-docker)
- [CLI Reference](/resources/cli)
+5
View File
@@ -45,6 +45,11 @@ export const sidebar = [
label: 'Tagging Rules',
slug: 'guides/tagging-rules',
},
// Will be uncommented after release
// {
// label: 'Roles and Administration',
// slug: 'guides/roles-administration',
// },
],
},
{
@@ -56,6 +56,12 @@ export const authConfig = {
default: false,
env: 'AUTH_SHOW_LEGAL_LINKS',
},
firstUserAsAdmin: {
doc: 'Automatically assign the admin role to the first user who registers. This is useful for initial setup of self-hosted instances where you need an admin account to manage the platform.',
schema: booleanishSchema,
default: true,
env: 'AUTH_FIRST_USER_AS_ADMIN',
},
ipAddressHeaders: {
doc: `The header, or comma separated list of headers, to use to get the real IP address of the user, use for rate limiting. Make sur to use a non-spoofable header, one set by your proxy.
- If behind a standard proxy, you might want to set this to "x-forwarded-for".
@@ -1,3 +1,4 @@
import type { Config } from '../../config/config.types';
import type { DocumentSearchServices } from '../../documents/document-search/document-search.types';
import type { TrackingServices } from '../../tracking/tracking.services';
import type { Database } from '../database/database.types';
@@ -11,9 +12,11 @@ import { registerTrackDocumentCreatedHandler } from '../../documents/events/trac
import { registerTriggerWebhooksOnDocumentCreatedHandler } from '../../documents/events/webhook.document-created';
import { registerTriggerWebhooksOnDocumentTrashedHandler } from '../../documents/events/webhook.document-trashed';
import { registerTriggerWebhooksOnDocumentUpdatedHandler } from '../../documents/events/webhook.document-updated';
import { registerFirstUserAdminEventHandler } from '../../roles/event-handlers/first-user-admin.user-created';
import { registerTrackingUserCreatedEventHandler } from '../../users/event-handlers/tracking.user-created';
export function registerEventHandlers(deps: { trackingServices: TrackingServices; eventServices: EventServices; db: Database; documentSearchServices: DocumentSearchServices }) {
export function registerEventHandlers(deps: { trackingServices: TrackingServices; eventServices: EventServices; db: Database; documentSearchServices: DocumentSearchServices; config: Config }) {
registerFirstUserAdminEventHandler(deps);
registerTrackingUserCreatedEventHandler(deps);
registerTriggerWebhooksOnDocumentCreatedHandler(deps);
registerInsertActivityLogOnDocumentCreatedHandler(deps);
@@ -29,7 +29,7 @@ export function createTestServerDependencies(overrides: Partial<GlobalDependenci
const subscriptionsServices = overrides.subscriptionsServices ?? createSubscriptionsServices({ config });
const documentSearchServices = overrides.documentSearchServices ?? createDocumentSearchServices({ db, config });
registerEventHandlers({ eventServices, trackingServices, db, documentSearchServices });
registerEventHandlers({ eventServices, trackingServices, db, documentSearchServices, config });
return {
config,
@@ -0,0 +1,129 @@
import { createNoopLogger } from '@crowlog/logger';
import { describe, expect, test } from 'vitest';
import { createInMemoryDatabase } from '../../app/database/database.test-utils';
import { createEventServices } from '../../app/events/events.services';
import { overrideConfig } from '../../config/config.test-utils';
import { nextTick } from '../../shared/async/defer.test-utils';
import { usersTable } from '../../users/users.table';
import { userRolesTable } from '../roles.table';
import { registerFirstUserAdminEventHandler } from './first-user-admin.user-created';
describe('first user admin assignment', () => {
describe('when the feature is disabled', () => {
test('the first user does not receive admin role', async () => {
const user = { id: 'usr_1', email: 'first@example.com', createdAt: new Date('2026-01-01') };
const { db } = await createInMemoryDatabase({ users: [user] });
const eventServices = createEventServices();
const config = overrideConfig({ auth: { firstUserAsAdmin: false } });
registerFirstUserAdminEventHandler({ eventServices, config, db, logger: createNoopLogger() });
eventServices.emitEvent({ eventName: 'user.created', payload: { userId: user.id, ...user } });
await nextTick();
const roles = await db.select().from(userRolesTable);
expect(roles).to.deep.equal([]);
});
});
describe('when the feature is enabled', () => {
test('the first user receives the admin role automatically', async () => {
const user = { id: 'usr_1', email: 'first@example.com', createdAt: new Date('2026-01-01') };
const { db } = await createInMemoryDatabase({ users: [user] });
const eventServices = createEventServices();
const config = overrideConfig({ auth: { firstUserAsAdmin: true } });
registerFirstUserAdminEventHandler({ eventServices, config, db, logger: createNoopLogger() });
eventServices.emitEvent({ eventName: 'user.created', payload: { userId: user.id, ...user } });
await nextTick();
const roles = await db.select().from(userRolesTable);
expect(
roles.map(({ userId, role }) => ({ userId, role })),
).to.deep.equal([
{ userId: 'usr_1', role: 'admin' },
]);
});
test('if the first user already has an admin role, it does not fail', async () => {
const user = { id: 'usr_1', email: 'first@example.com', createdAt: new Date('2026-01-01') };
const { db } = await createInMemoryDatabase({
users: [user],
userRoles: [{ userId: user.id, role: 'admin' }],
});
const eventServices = createEventServices();
const config = overrideConfig({ auth: { firstUserAsAdmin: true } });
registerFirstUserAdminEventHandler({ eventServices, config, db, logger: createNoopLogger() });
eventServices.emitEvent({ eventName: 'user.created', payload: { userId: user.id, ...user } });
await nextTick();
const roles = await db.select().from(userRolesTable);
expect(
roles.map(({ userId, role }) => ({ userId, role })),
).to.deep.equal([
{ userId: 'usr_1', role: 'admin' },
]);
});
test('the second user does not receive the admin role', async () => {
const { db } = await createInMemoryDatabase();
const eventServices = createEventServices();
const config = overrideConfig({ auth: { firstUserAsAdmin: true } });
registerFirstUserAdminEventHandler({ eventServices, config, db, logger: createNoopLogger() });
const firstUser = { id: 'usr_1', email: 'first@example.com', createdAt: new Date('2026-01-01') };
await db.insert(usersTable).values(firstUser);
eventServices.emitEvent({ eventName: 'user.created', payload: { userId: firstUser.id, ...firstUser } });
await nextTick();
const secondUser = { id: 'usr_2', email: 'second@example.com', createdAt: new Date('2026-01-02') };
await db.insert(usersTable).values(secondUser);
eventServices.emitEvent({ eventName: 'user.created', payload: { userId: secondUser.id, ...secondUser } });
await nextTick();
const roles = await db.select().from(userRolesTable);
expect(
roles.map(({ userId, role }) => ({ userId, role })),
).to.deep.equal([
{ userId: 'usr_1', role: 'admin' },
]);
});
test('when multiple users are already created, no one receives the admin role', async () => {
const users = [
{ id: 'usr_1', email: 'user1@example.com', createdAt: new Date('2026-01-01') },
{ id: 'usr_2', email: 'user2@example.com', createdAt: new Date('2026-01-01') },
].map(user => ({ ...user, userId: user.id }));
const { db } = await createInMemoryDatabase({ users });
const eventServices = createEventServices();
const config = overrideConfig({ auth: { firstUserAsAdmin: true } });
registerFirstUserAdminEventHandler({ eventServices, config, db, logger: createNoopLogger() });
eventServices.emitEvent({ eventName: 'user.created', payload: users[0]! });
eventServices.emitEvent({ eventName: 'user.created', payload: users[1]! });
await nextTick();
const roles = await db.select().from(userRolesTable);
expect(roles.length).to.equal(0);
});
});
});
@@ -0,0 +1,48 @@
import type { Database } from '../../app/database/database.types';
import type { EventServices } from '../../app/events/events.services';
import type { Config } from '../../config/config.types';
import type { Logger } from '../../shared/logger/logger';
import { createLogger } from '../../shared/logger/logger';
import { createUsersRepository } from '../../users/users.repository';
import { ROLES } from '../roles.constants';
import { createRolesRepository } from '../roles.repository';
export function registerFirstUserAdminEventHandler({
eventServices,
config,
logger = createLogger({ namespace: 'events:first-user-admin' }),
db,
}: {
eventServices: EventServices;
config: Config;
db: Database;
logger?: Logger;
}) {
const usersRepository = createUsersRepository({ db });
const rolesRepository = createRolesRepository({ db });
if (!config.auth.firstUserAsAdmin) {
return;
}
eventServices.onEvent({
eventName: 'user.created',
handlerName: 'roles.assign-admin-to-first-user',
handler: async ({ userId }) => {
if (!config.auth.firstUserAsAdmin) {
return;
}
const { userCount } = await usersRepository.getUserCount();
if (userCount !== 1) {
logger.debug({ userId, userCount }, 'User is not the first user, skipping admin assignment');
return;
}
await rolesRepository.assignRoleToUser({ userId, role: ROLES.ADMIN });
logger.info({ userId }, 'Admin role assigned to first user');
},
});
}
@@ -1,3 +1,7 @@
export const ROLES = {
ADMIN: 'admin',
} as const;
export const PERMISSIONS = {
BO_ACCESS: 'bo:access',
VIEW_USERS: 'users:view',
@@ -5,11 +9,11 @@ export const PERMISSIONS = {
} as const;
export const PERMISSIONS_BY_ROLE = {
admin: [
[ROLES.ADMIN]: [
PERMISSIONS.VIEW_USERS,
PERMISSIONS.BO_ACCESS,
PERMISSIONS.VIEW_ANALYTICS,
],
} as const;
export const ROLES = Object.keys(PERMISSIONS_BY_ROLE) as (keyof typeof PERMISSIONS_BY_ROLE)[];
export const ROLES_LIST = Object.values(ROLES);
@@ -38,8 +38,7 @@ async function assignRoleToUser({ userId, role, db }: { userId: string; role: Ro
userId,
role,
})
.onConflictDoNothing()
.returning();
.onConflictDoNothing();
}
async function removeRoleFromUser({ userId, role, db }: { userId: string; role: Role; db: Database }) {
@@ -1,4 +1,4 @@
import type { PERMISSIONS, ROLES } from './roles.constants';
import type { PERMISSIONS, ROLES_LIST } from './roles.constants';
export type Role = typeof ROLES[number];
export type Role = typeof ROLES_LIST[number];
export type Permission = typeof PERMISSIONS[keyof typeof PERMISSIONS];
+1 -1
View File
@@ -63,7 +63,7 @@ async function buildServices({ config }: { config: Config }): Promise<GlobalDepe
// --- Services initialization
await taskServices.initialize();
registerEventHandlers({ eventServices, trackingServices, db, documentSearchServices });
registerEventHandlers({ eventServices, trackingServices, db, documentSearchServices, config });
return {
config,