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
- Authenticate as an admin user for global secret writes.
- Promote the operating account to admin in the server's user management if it should manage global secrets.
- Include the X-Actual-File-Id header if the secret is meant to be per-budget instead of global.
- 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
- Reserve a dedicated admin token for global secret management
- Always include X-Actual-File-Id for per-budget secrets to avoid accidental global scope
- Promote operator accounts to admin only intentionally
- Document which credentials in your tooling are admin-scoped
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.