toeverything/AFFiNE · error · ActionForbidden

action_forbidden

action_forbidden

Error message

You are not allowed to perform this action.

What it means

Thrown inside SigningKeyService.delete's mutate callback when the target key exists but is not eligible for deletion: its status is not 'retiring', OR it has no verifyUntil, OR verifyUntil is still in the future (new Date(key.verifyUntil) >= now). Category 'action_forbidden', code 'action_forbidden'. Only retired keys past their verify-until cutoff may be purged; active or still-verifying keys must be kept so outstanding tokens remain validatable.

Source

Thrown at packages/backend/server/src/core/auth/signing-key.ts:192

  async delete(actorId: string, keyId: string) {
    const now = new Date();
    const updated = await this.models.appConfig.mutate(
      SIGNING_KEY_STORE_ID,
      actorId,
      value => {
        const current = this.parse(value);
        const key = current.find(key => key.id === keyId);
        if (!key) {
          throw new InvalidAppConfigInput({
            message: 'Signing key does not exist.',
          });
        }
        if (
          key.status !== 'retiring' ||
          !key.verifyUntil ||
          new Date(key.verifyUntil) >= now
        ) {
          throw new ActionForbidden();
        }
        return current.filter(key => key.id !== keyId);
      }
    );
    this.applyPersisted(updated.value);
    this.event.emit('auth.signing_key.deleted', { actorId, keyId });
    this.event.broadcast('auth.signing_keys.changed', {});
    return this.snapshotMetadata();
  }

  private applyPersisted(value: unknown) {
    const persisted = this.parse(value);
    this.replaceSnapshot(persisted);
  }

  private replaceSnapshot(keys: unknown) {
    const persisted = this.parse(keys);
    this.snapshot = persisted.map(key => {

View on GitHub (pinned to 26c515e050)

Solutions

  1. First rotate/retire the key so it enters 'retiring' status with a verifyUntil, then wait until now > verifyUntil before deleting.
  2. Confirm the key status === 'retiring' and verifyUntil is set and in the past before issuing delete.
  3. Schedule the cleanup (e.g. a delayed job) to run after verifyUntil rather than deleting manually.

Example fix

// before: delete immediately after retire
await signingKey.retire(keyId);
await signingKey.delete(actorId, keyId);

// after: delete only after the verify-until cutoff
const key = await getKey(keyId);
if (key.status !== 'retiring' || !key.verifyUntil || Date.now() < +new Date(key.verifyUntil)) {
  throw new Error('Key still within verify window; cannot delete yet');
}
await signingKey.delete(actorId, keyId);
Defensive patterns

Strategy: validation

Validate before calling

const key = (await signingKey.snapshotMetadata()).keys.find(k => k.id === keyId);
if (!key) throw new InvalidAppConfigInput({ message: 'Signing key does not exist.' });
const now = Date.now();
const deletable = key.status === 'retiring' && !!key.verifyUntil && now > +new Date(key.verifyUntil);
if (!deletable) throw new Error('Key is active or still within its verify window; cannot delete.');
await signingKey.delete(actorId, keyId);

Type guard

function isDeletable(k: { status: string; verifyUntil?: string }): boolean {
  return k.status === 'retiring' && !!k.verifyUntil && Date.now() > +new Date(k.verifyUntil);
}

Prevention

When it happens

Trigger: Calling delete(actorId, keyId) (signing-key.ts:187-194) for a key that is 'active', already deleted/unknown status, or 'retiring' but whose verifyUntil cutoff has not yet elapsed (tokens signed by it may still be within their access-token TTL + clock skew).

Common situations: Trying to immediately delete a key right after rotating it, before the verify-until window (accessTokenTtl + CLOCK_SKEW_SECONDS) expires; an admin expecting 'retire' to mean 'instantly deletable'; attempting to delete the currently-active key.

Related errors


AI-assisted analysis of toeverything/AFFiNE@26c515e050 (2026-08-12). Data as JSON: /api/errors/e660a7edf2d51906. Report an issue: GitHub.