actualbudget/actual · error

not-admin

not-admin

Error message

You have to be admin to manage global secrets

What it means

Global secrets (POST /secrets without an X-Actual-File-Id header) can only be managed by admin users; canManageGlobalSecrets(userId) delegates to isAdmin. A non-admin authenticated user attempting to set a server-wide secret gets HTTP 403 with reason 'not-admin'.

Source

Thrown at packages/sync-server/src/app-secrets.js:69

    if (!isValidFileId(fileId)) {
      res.status(400).send({
        status: 'error',
        reason: 'invalid-file-id',
        details: 'invalid fileId',
      });
      return;
    }

    if (!canManagePerBudgetFileSecrets(fileId, res.locals.user_id)) {
      res.status(403).send({
        status: 'error',
        reason: 'file-access-denied',
        details: "You don't have permissions over this file",
      });
      return;
    }
  } else if (!canManageGlobalSecrets(res.locals.user_id)) {
    res.status(403).send({
      status: 'error',
      reason: 'not-admin',
      details: 'You have to be admin to manage global secrets',
    });
    return;
  }

  const secretFileId = perBudgetFile ? fileId : null;
  secretsService.set(name, value, secretFileId);

  res.status(200).send({ status: 'ok' });
});

app.delete('/:name', async (req, res) => {
  const name = req.params.name;
  const fileId = req.get('X-Actual-File-Id');
  const perBudgetFile = fileId != null;

View on GitHub (pinned to d4334cb6e6)

Solutions

  1. Authenticate as an admin user for global secret writes.
  2. Promote the operating account to admin in the server's user management if it should manage global secrets.
  3. Include the X-Actual-File-Id header if the secret is meant to be per-budget instead of global.
  4. Split automations: use an admin token for global secrets and a user token for file-scoped ones.

Example fix

// before
// regular user token, no file header
POST /secrets {"name":"gocardless_secret_id","value":"..."} // 403 not-admin
// after
POST /secrets -H 'X-Actual-File-Id: <budgetId>' {"name":"gocardless_secret_id","value":"..."} // per-budget, owner allowed
Defensive patterns

Strategy: validation

Validate before calling

function assertGlobalSecretAllowed(user) {
  if (!user.isAdmin) throw new Error('Global secrets require an admin account');
}

Try / catch

const res = await fetch('/secrets', { method: 'POST', body: JSON.stringify({ name, value }) });
if (res.status === 403 && (await res.json()).reason === 'not-admin') {
  // switch to an admin session or add X-Actual-File-Id to scope per-budget
}

Prevention

When it happens

Trigger: POST /secrets with no X-Actual-File-Id header while authenticated as a regular (non-admin) user; automation using a non-admin token to configure global provider credentials; header accidentally omitted, downgrading a per-budget write to a global write.

Common situations: Self-hosted instances where the operator's account was never promoted to admin; scripts sharing one token across admins and regular users; forgetting the header so an intended per-budget secret is treated as global.

Related errors


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