mirror of
https://github.com/papra-hq/papra.git
synced 2026-05-08 11:22:08 -05:00
feat(admin): added first user admin role assignment and related documentation (#723)
This commit is contained in:
committed by
GitHub
parent
d37025cb94
commit
68d848e622
@@ -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)
|
||||
@@ -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,
|
||||
|
||||
+129
@@ -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];
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user