{"record":{"id":"e660a7edf2d51906","repo":"toeverything/AFFiNE","slug":"action-forbidden-e660a7","errorCode":"action_forbidden","errorMessage":"You are not allowed to perform this action.","messagePattern":"You are not allowed to perform this action\\.","errorType":"exception","errorClass":"ActionForbidden","httpStatus":403,"severity":"warning","filePath":"packages/backend/server/src/core/auth/signing-key.ts","lineNumber":192,"sourceCode":"  async delete(actorId: string, keyId: string) {\n    const now = new Date();\n    const updated = await this.models.appConfig.mutate(\n      SIGNING_KEY_STORE_ID,\n      actorId,\n      value => {\n        const current = this.parse(value);\n        const key = current.find(key => key.id === keyId);\n        if (!key) {\n          throw new InvalidAppConfigInput({\n            message: 'Signing key does not exist.',\n          });\n        }\n        if (\n          key.status !== 'retiring' ||\n          !key.verifyUntil ||\n          new Date(key.verifyUntil) >= now\n        ) {\n          throw new ActionForbidden();\n        }\n        return current.filter(key => key.id !== keyId);\n      }\n    );\n    this.applyPersisted(updated.value);\n    this.event.emit('auth.signing_key.deleted', { actorId, keyId });\n    this.event.broadcast('auth.signing_keys.changed', {});\n    return this.snapshotMetadata();\n  }\n\n  private applyPersisted(value: unknown) {\n    const persisted = this.parse(value);\n    this.replaceSnapshot(persisted);\n  }\n\n  private replaceSnapshot(keys: unknown) {\n    const persisted = this.parse(keys);\n    this.snapshot = persisted.map(key => {","sourceCodeStart":174,"sourceCodeEnd":210,"githubUrl":"https://github.com/toeverything/AFFiNE/blob/b4c8548c09da21b2898443559a5b846f0ccf5dd8/packages/backend/server/src/core/auth/signing-key.ts#L174-L210","documentation":"SigningKeyService.delete only removes keys already in the 'retiring' state whose verifyUntil timestamp is in the past — that grace period is how long old tokens signed by the key must still verify. Deleting an active key, or a retiring key before verifyUntil, throws ActionForbidden.","triggerScenarios":"Calling delete on a key whose status is 'active' (rotation never moved it to retiring), or on a 'retiring' key whose verifyUntil is still in the future (or missing), i.e. trying to delete immediately after rotate without waiting out the grace period.","commonSituations":"Admin rotates then deletes in one sitting without waiting for verifyUntil; UI offers delete on keys not yet eligible; misunderstanding of the lifecycle active -> retiring(verifyUntil) -> deletable.","solutions":["Wait until verifyUntil has passed, then delete — schedule the cleanup for after the grace period","If the key is still active, run rotate first so it moves to retiring with a verifyUntil","Gate the delete action in the UI on status === 'retiring' and verifyUntil < now, and show the eligible-at time"],"exampleFix":"// before\nawait signingKey.delete(actorId, keyId);\n\n// after\nconst key = snapshot.keys.find(k => k.id === keyId);\nif (!key || key.status !== 'retiring' || new Date(key.verifyUntil) >= new Date()) {\n  throw new Error('Key can only be deleted after rotation and once verifyUntil has passed');\n}\nawait signingKey.delete(actorId, keyId);","handlingStrategy":"validation","validationCode":"const key = snapshot.keys.find(k => k.id === keyId);\nconst deletable =\n  !!key &&\n  key.status === 'retiring' &&\n  !!key.verifyUntil &&\n  new Date(key.verifyUntil) < new Date();\nif (!deletable) {\n  throw new Error('Key must be retiring with verifyUntil in the past before delete');\n}\nawait signingKey.delete(actorId, keyId);","typeGuard":"function isActionForbidden(e: unknown): boolean {\n  return (\n    typeof e === 'object' &&\n    e !== null &&\n    'code' in e &&\n    (e as { code?: string }).code === 'action_forbidden'\n  );\n}","tryCatchPattern":"Catch action_forbidden from delete, reload the snapshot, and show why the key is not deletable yet (active, or verifyUntil in the future) with the exact eligible-at time.","preventionTips":["Learn the lifecycle: active -> retiring(verifyUntil) -> deletable","Rotate first, then schedule deletion for after verifyUntil elapses","Gate the delete action on status and verifyUntil in the admin UI"],"tags":["auth","signing-key","lifecycle","admin"],"backgroundTag":"lifecycle-state-violation","analyzedSha":"b4c8548c09da21b2898443559a5b846f0ccf5dd8","analyzedAt":"2026-08-18T21:16:52.546Z","contentChangedAt":"2026-08-18T21:16:52.546Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}