Skip to content

security: protect admin endpoints with an admin allowlist guard #97

Description

@EmeditWeb

Problem

src/modules/admin/audit.controller.ts is decorated @Controller('admin') and guarded with @UseGuards(JwtAuthGuard) only. There is no role check and no admin allowlist. Any wallet that completes the normal nonce-and-signature flow and holds a valid JWT can read the full admin audit log through GET /admin/..., including the record of who performed which privileged action and when.

The root cause is that the API has no concept of an administrator. USER_ROLES in src/modules/users/dto/set-role.dto.ts is ['sponsor', 'vendor', 'mentor']. There is no admin value, no ADMIN_WALLET or equivalent environment variable, and no admin guard anywhere in src/. RolesGuard exists and works, but there is no admin role for it to check.

This blocks other work as well as being a live exposure. Vendor approval, protocol pause, and parameter governance all need an authenticated administrator, and none of them can be built correctly until this exists.

Note the design constraint: roles are wallet-bound and permanent. POST /users/me/role returns 409 once set, per the 2026-07-16 tracker entry. Admin must therefore not be a self-assignable role. If it were, anyone could claim it.

Ground Rules

This issue must be solved according to StepFi's established engineering standards. Before writing any code:

  1. Read context/architecture-context.md in full
  2. Read context/code-standards.md in full
  3. Read context/progress-tracker.md in full, especially the 2026-07-16 entry on wallet-bound roles and RolesGuard, so this extends that design rather than contradicting it
  4. Read src/auth/guards/roles.guard.ts to understand the existing role-checking mechanism before adding to it
  5. Read src/modules/users/dto/set-role.dto.ts and src/modules/users/users.service.ts to understand how roles are assigned and why they are permanent
  6. Read src/config/env.ts for the existing configuration and validation pattern

Your PR will be rejected regardless of whether CI passes if it conflicts with anything in these files, introduces patterns inconsistent with what already exists, or duplicates functionality that was solved differently elsewhere in the codebase.

What To Build

  1. Add an ADMIN_WALLETS environment variable holding a comma-separated allowlist of Stellar addresses. Validate at startup in src/config/env.ts that every entry is a well-formed Stellar public key, and fail fast with a clear message if not. Do not hardcode any address in a service file.
  2. Do not add admin to USER_ROLES. Roles are self-selected and permanent, so a self-assignable admin role is a privilege escalation. Admin status must derive from the allowlist only.
  3. Create src/auth/guards/admin.guard.ts exporting an AdminGuard that resolves the authenticated wallet from the request and checks membership in the allowlist. Follow the structure of RolesGuard.
  4. Include the admin's JWT wallet address in the guard's rejection log, but return a generic 403 body to the caller. Do not leak the allowlist or its size in the response.
  5. Apply @UseGuards(JwtAuthGuard, AdminGuard) to audit.controller.ts.
  6. Audit every other controller for endpoints that assume an administrator but do not enforce one. Fix any found in this PR, and list them in the PR description.
  7. Add tests: a non-allowlisted authenticated wallet gets 403, an allowlisted wallet gets 200, an unauthenticated request gets 401, and an empty or unset ADMIN_WALLETS denies everyone rather than allowing everyone. That last case is the one that turns a config mistake into a breach, so it must be explicit.
  8. Document ADMIN_WALLETS in .env.example with a comment explaining that an empty value denies all admin access by design.

Files To Touch

  • src/auth/guards/admin.guard.ts (new)
  • src/auth/guards/admin.guard.spec.ts (new)
  • src/config/env.ts
  • src/modules/admin/audit.controller.ts
  • .env.example

Acceptance Criteria

  • GET /admin/* returns 403 for an authenticated wallet not on the allowlist
  • GET /admin/* returns 401 for an unauthenticated request
  • An unset or empty ADMIN_WALLETS denies all admin access rather than granting it
  • Startup fails with a clear message if any allowlist entry is not a valid Stellar address
  • No admin address is hardcoded in any service file
  • admin is not added to USER_ROLES and cannot be self-assigned via POST /users/me/role
  • The 403 response body does not disclose allowlist contents or size
  • All state-changing operations validated and safe against invalid input

Mandatory Checks Before Opening PR

  • All context/ files read and understood
  • Code follows context/code-standards.md exactly
  • context/progress-tracker.md updated with an accurate entry describing the change and why
  • Build passes with zero errors (npm run build)
  • All existing tests still pass, test count has not decreased
  • New tests written covering the new functionality
  • No new any types
  • Full Swagger decorators on any new or changed endpoint
  • PR template filled out completely, including the "problem this solves" and "how it was tested" sections
  • PR references this issue number exactly

PRs that fail any check above will be closed without review, regardless of how much work went into them. No exceptions. This is a Grantfox-funded, quality-first project. Code that does not meet these standards will not be merged and will not be paid.

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions