Skip to content

[Admin][API] Moderation — suspend user, unpublish/delete agent, cancel instance, roles/keys #34

Closed

Description

@serge-ivo

[Admin][API] Moderation — suspend user, unpublish/delete agent, cancel instance, manage roles/keys

Why

There is no operator lever to act on abuse or breakage. Every mutation here must be gated by requireAdmin and written to admin_audit_log.

Scope

  • Users: POST /v1/admin/users/:id/suspend + /unsuspend (a suspended flag on users, enforced in requireUser so a suspended user can't call the API). PUT /v1/admin/users/:id/roles (grant/revoke creator/admin).
  • Agents: POST /v1/admin/agents/:id/unpublish (set visibility=draft) and admin DELETE /v1/admin/agents/:id (reuse owner-or-admin delete already in agents.ts).
  • Instances: POST /v1/admin/instances/:id/cancel (operator cancel of a runaway/abusive instance).
  • Keys: POST /v1/admin/users/:id/keys/revoke (delete a stored provider key by provider — never reveal it).
  • Every action → admin_audit_log row.

Acceptance criteria

  • Suspended user is blocked at requireUser (401/403) until unsuspended; verified by test.
  • Unpublish flips visibility; admin delete works; instance cancel works.
  • Role changes reflect in the user's next session.
  • Every mutation writes exactly one audit row; non-admin → 403.

Relevant code

  • lib/auth.ts (requireUser suspension check), routes/agents.ts (delete), routes/instances.ts (cancel), user_api_keys
  • admin_audit_log (from Foundation issue)

References

  • FAS deprovision + FGS delist/archive + comp-grant revoke; PAS has no moderation (gap we close).

Part of the PAGS Admin Portal epic. Depends on Foundation.

Activity

  1. serge-ivo commented on Aug 6, 2026

    @serge-ivo
    ContributorAuthor

    Done in 5b7fe86. None of this existed before — verified by grep, not by reading the issue body.

    Routes (all in workers/api/src/routes/admin-moderation.ts, mounted at /v1/admin):

    POST /users/:id/suspend {reason?} / POST /users/:id/unsuspend reversible, reason stored + audited
    PUT /users/:id/roles {roles:[…]} closed set user/creator/admin; user always implied
    POST /users/:id/keys/revoke {provider} deletes by provider name, never decrypts
    POST /agents/:id/unpublish visibility → draft, records the previous value
    DELETE /agents/:id {confirm, force?} see below
    POST /instances/:id/cancel {reason?} operator cancel, by instance id only

    Suspension enforcement is in requireUser (migration 0078 adds users.suspended + suspended_at + suspended_reason), so it gates every authenticated route at once rather than being sprinkled per-handler where the next new route silently forgets it. Revoking the token would not have worked — sessions live 30 days and the user can simply sign in again for a fresh one. The check fails open on a D1 error: a moderation gate briefly not applying is a far smaller failure than 403ing the entire platform on a database blip.

    The authorization story, since these routes deliberately break the tenant-isolation invariant every other route upholds:

    • Every handler calls the existing requireAdmin itself — no fourth notion of "admin" invented, and operatorUserIds (lib/external-usage.ts) already reads the same users.roles + ADMIN_ALLOWLIST sources, so "who is an operator" stays one definition. The tests assert the 403 per route rather than inferring it from a shared helper: a handler that forgets the line must fail its own test.
    • Every successful mutation writes exactly one admin_audit_log row including the before-state (previous visibility, previous roles, previous status), so an action is reconstructable afterwards. Asserted per route.
    • Destructive actions name their target explicitly. There is no filter-shaped delete or cancel. Key revoke requires a provider — an omitted field must not become "wipe every key this user owns". Agent delete requires the caller to echo the agent's own slug (a mistyped id then 400s instead of destroying a different creator's agent) and 409s on an agent with live subscribers unless force, in which case those instances are canceled in the same batch rather than left stranded on a template that no longer exists.
    • Self-suspension and self-demotion are refused. With requireAdmin reading live roles, either one locks the only operator out of the portal that would undo it, effective on the very next request.
    • Parameterized SQL throughout; no secret is read, decrypted or returned.
    • The admin delete is separate from the owner-or-admin DELETE /v1/agents/:id in agents.ts on purpose: that one trusts session.roles, which only sees the role baked into a token, while this goes through the full live gate and audits.

    #108 (Cloudflare Access on the /v1/admin perimeter) is still open, so nothing upstream is covering for a missing gate here — these routes are safe on their own merits and the tests say so.

    suspended / suspended_at / suspended_reason are also surfaced on the admin user list + detail so an operator can see who is blocked.

    Tests: 41 new (routes/admin-moderation.test.ts + the suspension gate in lib/auth.test.ts) — 14 per-route auth-boundary cases, one-audit-row-per-mutation, the confirm/force guards, the self-protection refusals, the no-wildcard-revoke case, and fail-open-on-D1-error.

    Not covered: MCP is a separate worker with its own OAuth token verification and does not go through requireUser, so a suspended user's existing MCP token is not blocked by this. Worth a follow-up if suspension is ever used in anger.

  2. serge-ivo commented on Aug 6, 2026

    @serge-ivo
    ContributorAuthor

    Correction to the last paragraph above — I checked, and I had it backwards.

    The MCP worker does not read D1 directly: its tools proxy to api.proagentstore.online via apiCallAuthed, and the token they send (props.authToken, set in oauth-provider.ts at grant time) is the real PAGS session JWT. So those calls land on requireUser and the suspension check does apply — a suspended user's existing MCP grant stops working on its next tool call, no re-auth needed.

    The narrow residual gap is the handful of MCP tools that never touch the API: the repo/scaffold family in repo-tools.ts acts on GitHub with the worker's own GITHUB_TOKEN. Those bypass requireUser entirely and would keep working for a suspended user. Small surface, but a real one — worth closing if suspension is ever used in anger.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    admin-portalPAGS admin/operator portalenhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions