diff --git a/README.md b/README.md index e5769e2..216976f 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,8 @@ npm run seed # creates an admin user + prints an API key (shown npm run dev # API server on :4000 ``` +The seeded key has the `admin:api-keys` scope, which is required to provision additional keys via `POST /v1/admin/api-keys`. See the OpenAPI spec for full request/response shapes. + In separate terminals, as needed: ```bash @@ -149,7 +151,6 @@ CI (`.github/workflows/backend-ci.yml`) runs lint, typecheck, migrations, both t - **Behavioral/static checks are real but heuristic**, not a substitute for a manual audit — see the `details` field each check returns for exactly what was and wasn't inspected. - **No KYC provider integrated** — `KYC_PROVIDER_API_KEY` routes to manual analyst review only. - **Oracle key is loaded from env** (`ORACLE_SECRET_KEY`) for dev/testnet. Production requires swapping `shared/stellar.ts#loadOracleKeypair` for a KMS/HSM-backed signer. -- **No admin/self-serve key issuance endpoint** — `npm run seed` is the only bootstrap path; registry analysts and partners are meant to be provisioned internally, not through public signup. ## License diff --git a/db/seed.ts b/db/seed.ts index 901a015..973f1da 100644 --- a/db/seed.ts +++ b/db/seed.ts @@ -2,9 +2,8 @@ import { db, closeDb } from '../src/shared/db.js'; import { ensureUser, issueApiKey } from '../src/shared/api-keys.js'; /** - * Bootstraps a local/dev environment with one admin user + API key. There's no self-serve - * signup in this product (registry analysts and partners are provisioned internally), so this - * script — not a public endpoint — is the intended way to get a first usable key. + * Bootstraps a local/dev environment with one admin user + API key. This creates the initial + * key that can then be used to provision additional keys via POST /v1/admin/api-keys. */ async function main(): Promise { const email = process.argv[2] ?? 'admin@astraguard.dev'; @@ -12,7 +11,7 @@ async function main(): Promise { const ownerId = await ensureUser(email, 'admin'); const { rawKey, keyId } = await issueApiKey(ownerId, { label: 'seed-admin', - scopes: ['registry:review', 'certification:decide', 'claims:review'], + scopes: ['admin:api-keys', 'registry:review', 'certification:decide', 'claims:review'], rateLimitTier: 'internal', }); diff --git a/src/api/app.ts b/src/api/app.ts index a08d1f7..1e04718 100644 --- a/src/api/app.ts +++ b/src/api/app.ts @@ -14,6 +14,7 @@ import { registerRegistryRoutes } from './routes/registry.js'; import { registerCertificationRoutes } from './routes/certification.js'; import { registerClaimRoutes } from './routes/claims.js'; import { registerWebhookRoutes } from './routes/webhooks.js'; +import { registerAdminRoutes } from './routes/admin.js'; /** * Per-key rate limit tiers (api_keys.rate_limit_tier). Keys with no match, and anonymous @@ -78,6 +79,7 @@ export async function buildApp(): Promise { registerCertificationRoutes(app); registerClaimRoutes(app); registerWebhookRoutes(app); + registerAdminRoutes(app); return app; } diff --git a/src/api/openapi.yaml b/src/api/openapi.yaml index 6f1dc33..f7c9929 100644 --- a/src/api/openapi.yaml +++ b/src/api/openapi.yaml @@ -228,6 +228,93 @@ paths: responses: '200': { description: Webhook removed } + /v1/admin/api-keys: + get: + summary: List all API keys (admin view) + description: > + Returns all active API keys across all owners. Requires the `admin:api-keys` scope. + The raw key is never returned here — it is only available at issuance time. + parameters: + - name: includeRevoked + in: query + schema: { type: string, enum: ['true', 'false'], default: 'false' } + description: Include revoked keys in the response + - name: limit + in: query + schema: { type: integer, minimum: 1, maximum: 200, default: 50 } + - name: offset + in: query + schema: { type: integer, minimum: 0, default: 0 } + - name: ownerId + in: query + schema: { type: string, format: uuid } + description: Filter to a specific user's keys + responses: + '200': + description: List of API keys + content: + application/json: + schema: + type: object + properties: + apiKeys: + type: array + items: { $ref: '#/components/schemas/ApiKeyRecord' } + pagination: + type: object + properties: + limit: { type: integer } + offset: { type: integer } + '401': { description: Missing or invalid API key / insufficient scope } + post: + summary: Issue a new API key + description: > + Creates (or looks up) a user by email and issues a new API key with the specified + scopes and rate-limit tier. Requires the `admin:api-keys` scope. The `rawKey` in the + response is shown exactly once and is never stored — save it immediately. + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/IssueApiKeyRequest' } + responses: + '201': + description: API key issued + content: + application/json: + schema: + type: object + properties: + apiKey: { $ref: '#/components/schemas/IssuedApiKeyResponse' } + '400': { description: Validation error } + '401': { description: Missing or invalid API key / insufficient scope } + + /v1/admin/api-keys/{id}: + delete: + summary: Revoke an API key + description: > + Soft-deletes the key by setting `revoked_at`. The key is immediately rejected by the + auth middleware on subsequent requests. Returns 409 if the key is already revoked. + Requires the `admin:api-keys` scope. + parameters: + - name: id + in: path + required: true + schema: { type: string, format: uuid } + responses: + '200': + description: Key revoked + content: + application/json: + schema: + type: object + properties: + revoked: { type: boolean } + id: { type: string, format: uuid } + '401': { description: Missing or invalid API key / insufficient scope } + '404': { description: API key not found } + '409': { description: API key is already revoked } + components: securitySchemes: ApiKeyAuth: @@ -257,3 +344,60 @@ components: reasons: type: array items: { type: string } + ApiKeyRecord: + type: object + properties: + id: { type: string, format: uuid } + ownerId: { type: string, format: uuid } + ownerEmail: { type: string, format: email } + label: { type: string } + scopes: + type: array + items: { type: string } + rateLimitTier: { type: string, enum: [standard, partner, internal] } + createdAt: { type: string, format: date-time } + revokedAt: { type: string, format: date-time, nullable: true } + IssueApiKeyRequest: + type: object + required: [ownerEmail, scopes] + properties: + ownerEmail: + type: string + format: email + description: Email of the user to issue the key for; created if it doesn't exist + label: + type: string + minLength: 1 + maxLength: 100 + default: admin-issued + scopes: + type: array + minItems: 1 + items: + type: string + enum: [admin:api-keys, registry:review, certification:decide, claims:review] + rateLimitTier: + type: string + enum: [standard, partner, internal] + default: standard + role: + type: string + enum: [analyst, admin, partner] + default: analyst + description: Role assigned when creating a new user; ignored for existing users + IssuedApiKeyResponse: + type: object + properties: + id: { type: string, format: uuid } + ownerId: { type: string, format: uuid } + ownerEmail: { type: string, format: email } + label: { type: string } + scopes: + type: array + items: { type: string } + rateLimitTier: { type: string, enum: [standard, partner, internal] } + rawKey: + type: string + description: > + The raw API key — returned exactly once at issuance and never stored. + Store it immediately; it cannot be retrieved again. diff --git a/src/api/routes/admin.ts b/src/api/routes/admin.ts new file mode 100644 index 0000000..a6e2475 --- /dev/null +++ b/src/api/routes/admin.ts @@ -0,0 +1,174 @@ +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; +import { db } from '../../shared/db.js'; +import { ensureUser, issueApiKey, revokeApiKey } from '../../shared/api-keys.js'; +import { NotFoundError, ValidationError } from '../../shared/errors.js'; +import { requireApiKey } from '../middleware/auth.js'; + +const VALID_SCOPES = [ + 'admin:api-keys', + 'registry:review', + 'certification:decide', + 'claims:review', +] as const; + +const VALID_TIERS = ['standard', 'partner', 'internal'] as const; + +const issueKeySchema = z.object({ + /** Email of the user to issue the key for. Created if it doesn't exist. */ + ownerEmail: z.string().email(), + /** Human-readable label for the key (e.g. "prod-partner-acme"). */ + label: z.string().min(1).max(100).default('admin-issued'), + /** Scopes to grant. Must be a subset of the known scope list. */ + scopes: z + .array(z.enum(VALID_SCOPES)) + .min(1, 'At least one scope is required'), + /** Rate-limit tier for this key. */ + rateLimitTier: z.enum(VALID_TIERS).default('standard'), + /** Optional role to assign when creating a new user (ignored for existing users). */ + role: z.enum(['analyst', 'admin', 'partner']).default('analyst'), +}); + +const listQuerySchema = z.object({ + includeRevoked: z + .enum(['true', 'false']) + .transform((v) => v === 'true') + .default('false'), + limit: z.coerce.number().int().min(1).max(200).default(50), + offset: z.coerce.number().int().min(0).default(0), + ownerId: z.string().uuid().optional(), +}); + +const idParamsSchema = z.object({ id: z.string().uuid() }); + +/** + * Admin routes for API key management. + * All routes require the `admin:api-keys` scope — only keys seeded via `npm run seed` + * (or previously issued admin keys) can access these endpoints. + */ +export function registerAdminRoutes(app: FastifyInstance): void { + /** + * POST /v1/admin/api-keys + * Issue a new API key for a user (by email). The user is created if they don't exist. + * The raw key is returned exactly once and never stored. + */ + app.post( + '/v1/admin/api-keys', + { preHandler: requireApiKey('admin:api-keys') }, + async (req, reply) => { + const input = issueKeySchema.parse(req.body); + + // Ensure the target user exists (creates with given role if new). + const ownerId = await ensureUser(input.ownerEmail, input.role); + + const issued = await issueApiKey(ownerId, { + label: input.label, + scopes: input.scopes as string[], + rateLimitTier: input.rateLimitTier, + }); + + reply.status(201); + return { + apiKey: { + id: issued.keyId, + ownerId: issued.ownerId, + ownerEmail: input.ownerEmail, + label: input.label, + scopes: input.scopes, + rateLimitTier: input.rateLimitTier, + // Returned exactly once — not stored, never retrievable again. + rawKey: issued.rawKey, + }, + }; + }, + ); + + /** + * GET /v1/admin/api-keys + * List all API keys (admin-wide view). Supports filtering by owner and including revoked keys. + * The key_hash is never returned. + */ + app.get( + '/v1/admin/api-keys', + { preHandler: requireApiKey('admin:api-keys') }, + async (req) => { + const { includeRevoked, limit, offset, ownerId } = listQuerySchema.parse(req.query); + + // If filtering by a specific owner, verify that owner exists. + if (ownerId) { + const user = await db('users').where({ id: ownerId }).first(); + if (!user) throw new NotFoundError('User'); + } + + let query = db('api_keys') + .join('users', 'api_keys.owner_id', 'users.id') + .select( + 'api_keys.id', + 'api_keys.owner_id', + 'users.email as owner_email', + 'api_keys.label', + 'api_keys.scopes', + 'api_keys.rate_limit_tier', + 'api_keys.created_at', + 'api_keys.revoked_at', + ) + .orderBy('api_keys.created_at', 'desc') + .limit(limit) + .offset(offset); + + if (!includeRevoked) { + query = query.whereNull('api_keys.revoked_at'); + } + + if (ownerId) { + query = query.where('api_keys.owner_id', ownerId); + } + + const rows = await query; + + return { + apiKeys: rows.map((r) => ({ + id: r.id, + ownerId: r.owner_id, + ownerEmail: r.owner_email, + label: r.label, + scopes: r.scopes, + rateLimitTier: r.rate_limit_tier, + createdAt: r.created_at, + revokedAt: r.revoked_at ?? null, + })), + pagination: { limit, offset }, + }; + }, + ); + + /** + * DELETE /v1/admin/api-keys/:id + * Revoke an API key by ID (soft-delete via revoked_at). Idempotent — revoking an already- + * revoked key returns 409 so callers can distinguish "was active, now revoked" from + * "already revoked". + */ + app.delete( + '/v1/admin/api-keys/:id', + { preHandler: requireApiKey('admin:api-keys') }, + async (req) => { + const { id } = idParamsSchema.parse(req.params); + + // Verify the key exists before attempting revocation. + const key = await db('api_keys').where({ id }).first(); + if (!key) throw new NotFoundError('API key'); + + if (key.revoked_at) { + throw new ValidationError('API key is already revoked'); + } + + const revoked = await revokeApiKey(id); + if (!revoked) { + // Race condition: key was revoked between the check and the update. + throw new ValidationError('API key is already revoked'); + } + + return { revoked: true, id }; + }, + ); +} diff --git a/src/shared/api-keys.ts b/src/shared/api-keys.ts index 84605c2..7eb6262 100644 --- a/src/shared/api-keys.ts +++ b/src/shared/api-keys.ts @@ -45,3 +45,90 @@ export async function issueApiKey( return { rawKey, keyId: row.id, ownerId }; } + +/** + * Soft-deletes an API key by setting revoked_at. Returns true if the key existed and was + * revoked, false if it was already revoked or did not exist. + */ +export async function revokeApiKey(keyId: string): Promise { + const updated = await db('api_keys') + .where({ id: keyId }) + .whereNull('revoked_at') + .update({ revoked_at: new Date() }); + + return updated > 0; +} + +/** Returns all non-revoked (or all) API keys for a given owner, omitting the key_hash. */ +export async function listApiKeysForOwner( + ownerId: string, + opts: { includeRevoked?: boolean } = {}, +): Promise< + Array<{ + id: string; + ownerId: string; + label: string; + scopes: string[]; + rateLimitTier: string; + createdAt: Date; + revokedAt: Date | null; + }> +> { + let query = db('api_keys') + .where({ owner_id: ownerId }) + .select('id', 'owner_id', 'label', 'scopes', 'rate_limit_tier', 'created_at', 'revoked_at') + .orderBy('created_at', 'desc'); + + if (!opts.includeRevoked) { + query = query.whereNull('revoked_at'); + } + + const rows = await query; + return rows.map((r) => ({ + id: r.id, + ownerId: r.owner_id, + label: r.label, + scopes: r.scopes, + rateLimitTier: r.rate_limit_tier, + createdAt: r.created_at, + revokedAt: r.revoked_at ?? null, + })); +} + +/** Lists all API keys across all owners (admin view), excluding key_hash. */ +export async function listAllApiKeys(opts: { + includeRevoked?: boolean; + limit?: number; + offset?: number; +}): Promise< + Array<{ + id: string; + ownerId: string; + label: string; + scopes: string[]; + rateLimitTier: string; + createdAt: Date; + revokedAt: Date | null; + }> +> { + let query = db('api_keys') + .select('id', 'owner_id', 'label', 'scopes', 'rate_limit_tier', 'created_at', 'revoked_at') + .orderBy('created_at', 'desc') + .limit(opts.limit ?? 50) + .offset(opts.offset ?? 0); + + if (!opts.includeRevoked) { + query = query.whereNull('revoked_at'); + } + + const rows = await query; + return rows.map((r) => ({ + id: r.id, + ownerId: r.owner_id, + label: r.label, + scopes: r.scopes, + rateLimitTier: r.rate_limit_tier, + createdAt: r.created_at, + revokedAt: r.revoked_at ?? null, + })); +}