feat: admin controls
This commit is contained in:
+48
-29
@@ -1,5 +1,4 @@
|
||||
import { drizzleAdapter } from "@better-auth/drizzle-adapter/relations-v2";
|
||||
import { db } from "../server/lib/db";
|
||||
import { APIError, createAuthMiddleware, getSessionFromCtx, isAPIError } from 'better-auth/api';
|
||||
import { betterAuth } from 'better-auth/minimal';
|
||||
import { createAccessControl } from 'better-auth/plugins/access';
|
||||
@@ -8,23 +7,26 @@ import { defaultStatements } from 'better-auth/plugins/admin/access';
|
||||
import { twoFactor } from 'better-auth/plugins/two-factor';
|
||||
import * as schema from '../drizzle/schema';
|
||||
import { getRegistrationMode } from '../server/utils/instance';
|
||||
import { claimInvite, INVITE_TOKEN_HEADER, releaseInvite } from '../server/utils/invites';
|
||||
import { hasUsableAdministrator } from '../server/lib/account-security';
|
||||
import { authDatabase, currentTransaction } from '../server/lib/auth-transaction';
|
||||
import { claimInvite, INVITE_TOKEN_HEADER } from '../server/utils/invites';
|
||||
import { isSetupNeeded } from '../server/utils/setup';
|
||||
|
||||
const MIN_PASSWORD_LENGTH = 8;
|
||||
|
||||
const ADMIN_MUTATIONS = new Set(['/admin/set-role', '/admin/ban-user', '/admin/unban-user', '/admin/update-user']);
|
||||
|
||||
const transactionRequired = () => new APIError('INTERNAL_SERVER_ERROR', {
|
||||
message: 'This has to run in a transaction',
|
||||
code: 'TRANSACTION_REQUIRED',
|
||||
});
|
||||
|
||||
const accessControl = createAccessControl(defaultStatements);
|
||||
|
||||
// Admins manage accounts, not content. Signing in as someone else and setting
|
||||
// another user's password or email are left out on purpose: the first would
|
||||
// show their chats, the others would lock them out of their encrypted API keys.
|
||||
// Deleting is left out too, because Better Auth would only remove the account
|
||||
// and leave its agents, topics and files behind. Creating is left out so new
|
||||
// accounts are always made without a password and with a setup link.
|
||||
// /api/admin/users does both.
|
||||
const roles = {
|
||||
admin: accessControl.newRole({
|
||||
user: ['list', 'set-role', 'ban', 'get', 'update'],
|
||||
session: ['list', 'revoke', 'delete'],
|
||||
user: ['set-role', 'ban'],
|
||||
session: ['revoke'],
|
||||
}),
|
||||
user: accessControl.newRole({
|
||||
user: [],
|
||||
@@ -33,7 +35,7 @@ const roles = {
|
||||
};
|
||||
|
||||
export const auth = betterAuth({
|
||||
database: drizzleAdapter(db, {
|
||||
database: drizzleAdapter(authDatabase, {
|
||||
provider: "pg",
|
||||
schema,
|
||||
usePlural: true,
|
||||
@@ -44,7 +46,7 @@ export const auth = betterAuth({
|
||||
updateAge: 60 * 60 * 24 * 1, // 1 day
|
||||
cookieCache: {
|
||||
enabled: true,
|
||||
maxAge: 5 * 60,
|
||||
maxAge: 60,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -53,8 +55,21 @@ export const auth = betterAuth({
|
||||
|
||||
hooks: {
|
||||
before: createAuthMiddleware(async (ctx) => {
|
||||
// Who may register is an instance setting admins change at runtime.
|
||||
if (ctx.path === "/change-password" && ctx.request) {
|
||||
throw new APIError("FORBIDDEN", {
|
||||
message: "Use the account password endpoint",
|
||||
code: "USE_ACCOUNT_PASSWORD_ENDPOINT",
|
||||
});
|
||||
}
|
||||
|
||||
if (ctx.path === '/sign-up/email') {
|
||||
if (await isSetupNeeded()) {
|
||||
throw new APIError('FORBIDDEN', {
|
||||
message: 'This instance has not been set up yet',
|
||||
code: 'SETUP_NEEDED',
|
||||
});
|
||||
}
|
||||
|
||||
const mode = await getRegistrationMode();
|
||||
|
||||
if (mode === 'closed') {
|
||||
@@ -65,9 +80,12 @@ export const auth = betterAuth({
|
||||
}
|
||||
|
||||
if (mode === 'invite') {
|
||||
const tx = currentTransaction();
|
||||
if (!tx) throw transactionRequired();
|
||||
|
||||
const token = ctx.headers?.get(INVITE_TOKEN_HEADER);
|
||||
|
||||
if (!token || !await claimInvite(token)) {
|
||||
if (!token || !await claimInvite(tx, token)) {
|
||||
throw new APIError('FORBIDDEN', {
|
||||
message: 'This invite link is not valid anymore',
|
||||
code: 'INVITE_INVALID',
|
||||
@@ -76,9 +94,6 @@ export const auth = betterAuth({
|
||||
}
|
||||
}
|
||||
|
||||
// Creating a user as an admin skips the sign up password rules. No
|
||||
// password at all is fine: that account can't be signed in to until
|
||||
// its owner sets one with a setup link.
|
||||
if (ctx.path === '/admin/create-user' && ctx.body?.password !== undefined) {
|
||||
const password = ctx.body.password;
|
||||
if (typeof password !== 'string' || password.length < MIN_PASSWORD_LENGTH) {
|
||||
@@ -89,12 +104,13 @@ export const auth = betterAuth({
|
||||
}
|
||||
}
|
||||
|
||||
// The admin endpoints already stop admins from removing or disabling
|
||||
// themselves. Giving up their own admin role is the one way left to
|
||||
// end up with an instance nobody can manage, so that is refused too.
|
||||
if (ctx.path !== '/admin/set-role' && ctx.path !== '/admin/update-user') return;
|
||||
if (!ADMIN_MUTATIONS.has(ctx.path)) return;
|
||||
|
||||
const role = ctx.path === '/admin/set-role' ? ctx.body?.role : ctx.body?.data?.role;
|
||||
if (!currentTransaction()) throw transactionRequired();
|
||||
|
||||
if (ctx.path !== '/admin/set-role') return;
|
||||
|
||||
const role = ctx.body?.role;
|
||||
if (role === undefined) return;
|
||||
|
||||
const roleList = (Array.isArray(role) ? role : String(role).split(',')).map((r) => String(r).trim());
|
||||
@@ -109,14 +125,17 @@ export const auth = betterAuth({
|
||||
}
|
||||
}),
|
||||
|
||||
// The invite was claimed before the account was made. When making it
|
||||
// then fails, say because the email is taken, the use is given back.
|
||||
after: createAuthMiddleware(async (ctx) => {
|
||||
if (ctx.path !== '/sign-up/email' || !isAPIError(ctx.context.returned)) return;
|
||||
if (!ADMIN_MUTATIONS.has(ctx.path) || isAPIError(ctx.context.returned)) return;
|
||||
|
||||
const token = ctx.headers?.get(INVITE_TOKEN_HEADER);
|
||||
if (token && await getRegistrationMode() === 'invite') {
|
||||
await releaseInvite(token);
|
||||
const tx = currentTransaction();
|
||||
if (!tx) throw transactionRequired();
|
||||
|
||||
if (!await hasUsableAdministrator(tx)) {
|
||||
throw new APIError('CONFLICT', {
|
||||
message: 'The instance must keep a usable administrator',
|
||||
code: 'LAST_ADMIN',
|
||||
});
|
||||
}
|
||||
}),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user