Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
7 changes: 3 additions & 4 deletions db/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,16 @@ 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<void> {
const email = process.argv[2] ?? 'admin@astraguard.dev';

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',
});

Expand Down
2 changes: 2 additions & 0 deletions src/api/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -78,6 +79,7 @@ export async function buildApp(): Promise<FastifyInstance> {
registerCertificationRoutes(app);
registerClaimRoutes(app);
registerWebhookRoutes(app);
registerAdminRoutes(app);

return app;
}
Expand Down
144 changes: 144 additions & 0 deletions src/api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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.
174 changes: 174 additions & 0 deletions src/api/routes/admin.ts
Original file line number Diff line number Diff line change
@@ -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 };
},
);
}
Loading
Loading