{"record":{"id":"e35c459268cc4fa4","repo":"actualbudget/actual","slug":"forbidden-e35c45","errorCode":"forbidden","errorMessage":"permission-not-found","messagePattern":"permission-not-found","errorType":"http","errorClass":null,"httpStatus":403,"severity":"warning","filePath":"packages/sync-server/src/app-account.js","lineNumber":136,"sourceCode":"      tokenRes = await loginWithPassword(req.body.password);\n      break;\n  }\n  const { error, token } = tokenRes;\n\n  if (error) {\n    res.status(400).send({ status: 'error', reason: error });\n    return;\n  }\n\n  res.send({ status: 'ok', data: { token } });\n});\n\napp.post('/change-password', async (req, res) => {\n  const session = validateSession(req, res);\n  if (!session) return;\n\n  if (!isAdmin(session.user_id)) {\n    res.status(403).send({\n      status: 'error',\n      reason: 'forbidden',\n      details: 'permission-not-found',\n    });\n    return;\n  }\n\n  if (session.auth_method !== 'password') {\n    res.status(403).send({\n      status: 'error',\n      reason: 'forbidden',\n      details: 'password-auth-not-active',\n    });\n    return;\n  }\n\n  const { error } = await changePassword(req.body.password);\n","sourceCodeStart":118,"sourceCodeEnd":154,"githubUrl":"https://github.com/actualbudget/actual/blob/d4334cb6e6123f4d3bcea1ad6166608884c7e658/packages/sync-server/src/app-account.js#L118-L154","documentation":"HTTP 403 from POST /change-password. The session is valid, but the authenticated user is not an admin, so the server refuses the password change with `reason:'forbidden', details:'permission-not-found'`. In Actual's sync-server only admin users may change the server password via this endpoint.","triggerScenarios":"An authenticated non-admin user (or a client acting with their token) calls POST /change-password. Any valid session whose `user_id` fails the `isAdmin(user_id)` check in account-db.","commonSituations":"Multi-user/OpenID setups where a regular family member's client tries to rotate the server password; scripts that reuse a non-admin token; confusion between the per-user OpenID password flow and the admin server-password flow.","solutions":["Log in as (or obtain a token for) an admin user before calling POST /change-password.","Grant the `admin` role to the user in the account database (via the admin UI or the users table) if they legitimately need this right.","If a non-admin only needs to change their own credentials, use the identity-provider (OpenID) flow instead of this endpoint."],"exampleFix":"// before: calling with a regular user's token\nawait api.post('/change-password', { password }, { headers: { 'X-ACTUAL-TOKEN': userToken } });\n// after: use an admin session token\nawait api.post('/change-password', { password }, { headers: { 'X-ACTUAL-TOKEN': adminToken } });","handlingStrategy":"validation","validationCode":"// check the session's role before calling admin-only endpoints\nconst v = await get('/validate', { headers: authHeaders(token) });\nif (v.data.data.permission !== 'admin') throw new Error('Admin session required');","typeGuard":"function isAdminSession(session) {\n  return session != null && session.permission === 'admin';\n}","tryCatchPattern":"try {\n  await post('/change-password', { password }, { headers: authHeaders(token) });\n} catch (e) {\n  if (e.response?.status === 403 && e.response.data.details === 'permission-not-found') {\n    notifyAdminRightsRequired();\n  } else throw e;\n}","preventionTips":["Gate admin-only UI actions on the `permission` value returned by GET /validate","Use dedicated admin credentials for automation","Do not reuse regular-user tokens for privileged operations"],"tags":["authorization","http-403","forbidden","admin-only"],"backgroundTag":"insufficient-permissions","analyzedSha":"d4334cb6e6123f4d3bcea1ad6166608884c7e658","analyzedAt":"2026-08-29T01:02:11.213Z","schemaVersion":2},"datasetVersion":"2026-08-29T02:17:18.158Z"}