actualbudget/actual · warning

forbidden

forbidden

Error message

permission-not-found

What it means

HTTP 403 from POST /users (create user). The request passed `validateSessionMiddleware`, but `isAdmin(res.locals.user_id)` is false, so user creation — an admin-only operation — is rejected with `reason:'forbidden', details:'permission-not-found'`.

Source

Thrown at packages/sync-server/src/app-admin.js:49

// (wont-fix). Actual's multi-user/OpenID feature is intended for friends &
// family setups, not SaaS, so the attack surface is low. The endpoint is also
// used in the budget ownership transfer flow, where neither the current nor the
// target user is necessarily an admin — adding isAdmin would break that flow
// without a substantial refactor.
app.get('/users/', validateSessionMiddleware, (req, res) => {
  const users = UserService.getAllUsers();
  res.json(
    users.map(u => ({
      ...u,
      owner: u.owner === 1,
      enabled: u.enabled === 1,
    })),
  );
});

app.post('/users', validateSessionMiddleware, async (req, res) => {
  if (!isAdmin(res.locals.user_id)) {
    res.status(403).send({
      status: 'error',
      reason: 'forbidden',
      details: 'permission-not-found',
    });
    return;
  }

  const { userName, role, displayName, enabled } = req.body || {};

  if (!userName || !role) {
    res.status(400).send({
      status: 'error',
      reason: `${!userName ? 'user-cant-be-empty' : 'role-cant-be-empty'}`,
      details: `${!userName ? 'Username' : 'Role'} cannot be empty`,
    });
    return;
  }

View on GitHub (pinned to d4334cb6e6)

Solutions

  1. Call POST /users with an admin user's token.
  2. Grant the admin role to the intended user via an existing admin session or directly in the account database.
  3. Bootstrap the first owner/admin user if no admin exists (fresh multi-user setup), then use that account for administration.

Example fix

// before
await api.post('/users', payload, { headers: { 'X-ACTUAL-TOKEN': memberToken } });
// after
await api.post('/users', payload, { headers: { 'X-ACTUAL-TOKEN': adminToken } });
Defensive patterns

Strategy: validation

Validate before calling

const v = await get('/validate', { headers: authHeaders(token) });
if (v.data.data.permission !== 'admin') throw new Error('User creation requires an admin session');

Type guard

function isAdminSession(session) {
  return session != null && session.permission === 'admin';
}

Try / catch

try {
  await post('/users', payload, { headers: authHeaders(token) });
} catch (e) {
  if (e.response?.status === 403 && e.response.data.details === 'permission-not-found') {
    throw new AdminRequiredError('Use an admin token to create users');
  }
  throw e;
}

Prevention

When it happens

Trigger: An authenticated non-admin user calls POST /users to create a new account. Same guard also applies to PATCH /users and DELETE /users.

Common situations: Automation using a service token belonging to a regular user; admin role revoked from the account the script was set up with; OpenID-managed users where nobody was granted the admin role.

Related errors


AI-assisted analysis of actualbudget/actual@d4334cb6e6 (2026-08-29). Data as JSON: /api/errors/545255e6119c5343. Report an issue: GitHub.