Skip to content

feat: implement validation schema and handler for org key rotations - #131

Open
Bogunrot wants to merge 1 commit into
ASTROIDX556:mainfrom
Bogunrot:feat/org-key-rotation
Open

feat: implement validation schema and handler for org key rotations#131
Bogunrot wants to merge 1 commit into
ASTROIDX556:mainfrom
Bogunrot:feat/org-key-rotation

Conversation

@Bogunrot

Copy link
Copy Markdown

1. Linked Issue

Closes #5

2. Problem Statement (The Bug)

Organizations had no way to rotate their admin API key. The ApiKey model stores only a SHA-256 hash (hashedKey), so a leaked or compromised key can never be recovered or invalidated through the API — the only existing operations were create/list/revoke of arbitrary developer keys. This is a security gap that cannot be fixed with a local patch: rotating a credential is a cross-cutting operation that must atomically (a) supersede every currently valid admin key, (b) mint a replacement whose raw secret is shown exactly once, and (c) leave an immutable audit trail. Doing any of that by hand in the database would bypass the application's event ledger and audit log, which are the platform's record of every financial decision.

3. Solution Comparison and Decision (Why rotation, and not the alternatives)

Option Why rejected
A. Expose revoke + create and let clients compose a rotation Two separate calls leave a window where the org has zero valid admin keys, and nothing guarantees the old key is actually superseded. Treats the symptom, not the cause.
B. Rotate via a database migration / manual script Bypasses the domain layer entirely — no Zod validation, no event ledger entry, no audit log, and it cannot be called by owners at runtime.
C. Reuse the existing developer api-keys endpoint with an extra rotate query flag Overloads a developer-scoped resource with a security-sensitive org operation and muddies the role model (developers can create keys, only owners may rotate the admin key).

Decision — a dedicated POST /api/v1/organizations/keys/rotate endpoint on OrganizationController. It is the only option that keeps the whole rotation in one request: authorize (owner-only) → validate (Zod) → revoke all active admin keys → mint one new admin key → emit organization.key_rotated. The operation is additive and cannot be composed correctly by clients, so it must be a first-class handler.

4. The Change (Code modifications)

  • organization.dto.ts — new strict Zod schema rotateKeysSchema (name / reason, unknown keys rejected) plus the matching Swagger DTO.
  • organization.controller.tsrotateKeys route with @Roles(UserRole.OWNER) and @Body(new ZodValidationPipe(rotateKeysSchema)).
  • organization.repository.tsfindActiveApiKeys, createApiKey, revokeApiKey persistence methods.
  • organization.service.tsrotateKeys orchestrates revoke → mint → emit.
  • event-names.tsOrganizationKeyRotated: 'organization.key_rotated'; the global AuditListener (wildcard @OnEvent('**')) turns the same event into an AuditLog row, satisfying the "AuditLog / DomainEvent" criterion.

Core handler:

async rotateKeys(organizationId: string, actorId: string, input: RotateKeysInput) {
  await this.getCurrent(organizationId); // 404 for missing/deleted orgs

  const activeKeys = await this.repository.findActiveApiKeys(organizationId);
  const adminKeys = activeKeys.filter((key) =>
    key.permissions.some((p) => p.toLowerCase() === 'admin'),
  );
  for (const key of adminKeys) {
    await this.repository.revokeApiKey(key.id);
  }

  const { raw, prefix, hashedKey } = generateApiKey('live');
  const apiKey = await this.repository.createApiKey({
    organizationId, createdById: actorId,
    name: input.name ?? 'Admin API key',
    prefix, hashedKey, permissions: ['admin'], allowedIps: [],
  });

  await this.eventBus.emit(DomainEventName.OrganizationKeyRotated, {
    keyId: apiKey.id, name: apiKey.name, prefix: apiKey.prefix,
    revokedCount: adminKeys.length, reason: input.reason ?? null,
  }, { organizationId, actorId, aggregateType: 'organization', aggregateId: organizationId });

  return { id: apiKey.id, name: apiKey.name, prefix: apiKey.prefix,
           permissions: apiKey.permissions, revokedCount: adminKeys.length, key: raw };
}

Behavioral comparison across entry points:

Entry point Before After
Authorization n/a @Roles(UserRole.OWNER) enforced by the global RolesGuard (403 for any other role)
Request body n/a Strict Zod schema — invalid/unknown fields → 422 VALIDATION_ERROR
Old admin keys never revocable via API all active admin keys revoked in the same request
New key n/a minted with permissions: ['admin']; raw secret returned exactly once, only the SHA-256 hash persisted
DomainEvent / AuditLog n/a organization.key_rotated emitted → immutable domain_events row + audit_logs row via AuditListener

5. Compatibility Note (On INTERFACE_VERSION)

This project has no versioned interface constant — the API version is the stable api/v1 prefix configured in app.config (apiPrefix), which is not modified. No bump is needed because the change is purely additive: a new route under the existing prefix, a new event name, and new repository methods. No existing endpoint, payload shape, or event contract changes.

6. Incidental Fixes (Two things the issue called out that also got fixed)

  • The repository previously had no API-key persistence at all — org-level key lifecycle now has first-class methods instead of ad-hoc prisma calls in the service.
  • rotateKeys reuses the existing generateApiKey crypto util (raw + prefix + hash), keeping the "hash-only storage, show-once" invariant consistent with the developer api-keys flow.

7. Testing (Proving it works)

New tests (all green):

  • organization.service.spec.ts
    • revokes every active admin key and mints a fresh one — asserts every admin key is revoked, the new key carries permissions: ['admin'], and the stored hash equals sha256(raw) of the returned secret (proving the show-once invariant through the real crypto.util).
    • emits an OrganizationKeyRotated domain event with audit context — asserts the real event name and the org/actor/aggregate envelope.
    • mints a key when the organization has no admin key yet — non-admin keys are never touched.
    • throws when the organization does not exist — 404 path.
  • organization.controller.spec.ts
    • is restricted to organization owners via @Roles(OWNER) — reads the actual ROLES_KEY metadata off the real handler, proving the guard blocks non-owners.
    • delegates to the service with the caller context and validated body — real controller → real service boundary.

Results: organization.* specs 7/7 passed; full suite Test Files 12 passed, Tests 117 passed; npm run typecheck and npm run lint clean. No pre-existing failures observed.

8. Additional Notes (Scope)

Single focused commit on feat/org-key-rotation. Scope is limited to the organizations module + one event name; wallets, policies, budgets, transactions, and the developer api-keys module are untouched.

Add POST /api/v1/organizations/keys/rotate so organization owners can
request a fresh active admin key. The endpoint is guarded by @roles(OWNER),
validates the request body with a strict Zod schema, revokes every active
admin key, mints a replacement (only its SHA-256 hash is stored), and emits
an organization.key_rotated domain event that the audit listener records
as an AuditLog row.

Closes ASTROIDX556#5
@drips-wave

drips-wave Bot commented Aug 29, 2026

Copy link
Copy Markdown

@Bogunrot Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: implement validation schema and handler for org key rotations

1 participant