|
@@ -0,0 +1,739 @@
|
|
|
|
|
+<?php
|
|
|
|
|
+
|
|
|
|
|
+declare(strict_types=1);
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * Hand-curated OpenAPI 3.0 spec for IRDB.
|
|
|
|
|
+ *
|
|
|
|
|
+ * Run: `composer openapi:build` (or `php api/openapi.php > api/public/openapi.yaml`).
|
|
|
|
|
+ * The committed `api/public/openapi.yaml` MUST match this file's output —
|
|
|
|
|
+ * the doc-accuracy CI guard (`scripts/check-doc-endpoints.sh`) treats the
|
|
|
|
|
+ * generated spec as the source of truth for which endpoints exist.
|
|
|
|
|
+ *
|
|
|
|
|
+ * Why hand-curated and not annotation-based: keeps the API surface in one
|
|
|
|
|
+ * place where it can be reviewed in PR diffs, avoids pulling
|
|
|
|
|
+ * zircote/swagger-php into prod deps, and the static array fits in <500
|
|
|
|
|
+ * lines with room for descriptions. Trade-off: drift between code and
|
|
|
|
|
+ * spec is possible — the integration tests assert response shapes
|
|
|
|
|
+ * separately, and CI runs `redocly lint` against the YAML to catch
|
|
|
|
|
+ * malformed entries.
|
|
|
|
|
+ *
|
|
|
|
|
+ * Internal endpoints (`/internal/jobs/*`) are deliberately omitted per
|
|
|
|
|
+ * SPEC §M13.1; they're scheduler-only and not part of the public
|
|
|
|
|
+ * contract.
|
|
|
|
|
+ */
|
|
|
|
|
+
|
|
|
|
|
+require_once __DIR__ . '/vendor/autoload.php';
|
|
|
|
|
+
|
|
|
|
|
+$errorEnvelope = [
|
|
|
|
|
+ 'type' => 'object',
|
|
|
|
|
+ 'required' => ['error'],
|
|
|
|
|
+ 'properties' => [
|
|
|
|
|
+ 'error' => ['type' => 'string', 'example' => 'unauthorized'],
|
|
|
|
|
+ 'details' => [
|
|
|
|
|
+ 'type' => 'object',
|
|
|
|
|
+ 'description' => 'Field-level errors for `validation_failed`.',
|
|
|
|
|
+ 'additionalProperties' => ['type' => 'string'],
|
|
|
|
|
+ ],
|
|
|
|
|
+ ],
|
|
|
|
|
+];
|
|
|
|
|
+
|
|
|
|
|
+$pageMeta = [
|
|
|
|
|
+ 'page' => ['type' => 'integer', 'minimum' => 1, 'example' => 1],
|
|
|
|
|
+ 'page_size' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 200, 'example' => 50],
|
|
|
|
|
+ 'total' => ['type' => 'integer', 'minimum' => 0, 'example' => 1284],
|
|
|
|
|
+];
|
|
|
|
|
+
|
|
|
|
|
+$components = [
|
|
|
|
|
+ 'securitySchemes' => [
|
|
|
|
|
+ 'BearerAuth' => [
|
|
|
|
|
+ 'type' => 'http',
|
|
|
|
|
+ 'scheme' => 'bearer',
|
|
|
|
|
+ 'description' => "Token in the form `irdb_<kind>_<32 base32 chars>`.\n"
|
|
|
|
|
+ . 'See `doc/auth-flows.md` for the four token kinds.',
|
|
|
|
|
+ ],
|
|
|
|
|
+ ],
|
|
|
|
|
+ 'parameters' => [
|
|
|
|
|
+ 'Page' => [
|
|
|
|
|
+ 'name' => 'page',
|
|
|
|
|
+ 'in' => 'query',
|
|
|
|
|
+ 'schema' => ['type' => 'integer', 'minimum' => 1, 'default' => 1],
|
|
|
|
|
+ ],
|
|
|
|
|
+ 'PageSize' => [
|
|
|
|
|
+ 'name' => 'page_size',
|
|
|
|
|
+ 'in' => 'query',
|
|
|
|
|
+ 'schema' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 200, 'default' => 50],
|
|
|
|
|
+ ],
|
|
|
|
|
+ 'ActingUserId' => [
|
|
|
|
|
+ 'name' => 'X-Acting-User-Id',
|
|
|
|
|
+ 'in' => 'header',
|
|
|
|
|
+ 'description' => "Required when authenticating with a `service` token.\n"
|
|
|
|
|
+ . 'The api applies RBAC for the named user. Ignored on other token kinds.',
|
|
|
|
|
+ 'schema' => ['type' => 'integer'],
|
|
|
|
|
+ ],
|
|
|
|
|
+ ],
|
|
|
|
|
+ 'schemas' => [
|
|
|
|
|
+ 'Error' => $errorEnvelope,
|
|
|
|
|
+ 'ReportRequest' => [
|
|
|
|
|
+ 'type' => 'object',
|
|
|
|
|
+ 'required' => ['ip', 'category'],
|
|
|
|
|
+ 'properties' => [
|
|
|
|
|
+ 'ip' => ['type' => 'string', 'example' => '203.0.113.42'],
|
|
|
|
|
+ 'category' => ['type' => 'string', 'example' => 'brute_force'],
|
|
|
|
|
+ 'metadata' => [
|
|
|
|
|
+ 'type' => 'object',
|
|
|
|
|
+ 'description' => 'Free-form per-report data, max 4 KB after json_encode.',
|
|
|
|
|
+ 'additionalProperties' => true,
|
|
|
|
|
+ ],
|
|
|
|
|
+ ],
|
|
|
|
|
+ ],
|
|
|
|
|
+ 'ReportResponse' => [
|
|
|
|
|
+ 'type' => 'object',
|
|
|
|
|
+ 'properties' => [
|
|
|
|
|
+ 'report_id' => ['type' => 'integer', 'example' => 12345],
|
|
|
|
|
+ 'ip' => ['type' => 'string', 'example' => '203.0.113.42'],
|
|
|
|
|
+ 'received_at' => ['type' => 'string', 'format' => 'date-time'],
|
|
|
|
|
+ ],
|
|
|
|
|
+ ],
|
|
|
|
|
+ 'BlocklistEntry' => [
|
|
|
|
|
+ 'type' => 'object',
|
|
|
|
|
+ 'properties' => [
|
|
|
|
|
+ 'ip_or_cidr' => ['type' => 'string', 'example' => '203.0.113.42'],
|
|
|
|
|
+ 'categories' => ['type' => 'array', 'items' => ['type' => 'string']],
|
|
|
|
|
+ 'score' => ['type' => 'number', 'format' => 'float', 'example' => 1.42],
|
|
|
|
|
+ 'reason' => ['type' => 'string', 'enum' => ['scored', 'manual'], 'example' => 'scored'],
|
|
|
|
|
+ ],
|
|
|
|
|
+ ],
|
|
|
|
|
+ 'BlocklistJson' => [
|
|
|
|
|
+ 'type' => 'object',
|
|
|
|
|
+ 'properties' => [
|
|
|
|
|
+ 'count' => ['type' => 'integer', 'example' => 42],
|
|
|
|
|
+ 'generated_at' => ['type' => 'string', 'format' => 'date-time'],
|
|
|
|
|
+ 'policy' => ['type' => 'string', 'example' => 'moderate'],
|
|
|
|
|
+ 'entries' => ['type' => 'array', 'items' => ['$ref' => '#/components/schemas/BlocklistEntry']],
|
|
|
|
|
+ ],
|
|
|
|
|
+ ],
|
|
|
|
|
+ 'Token' => [
|
|
|
|
|
+ 'type' => 'object',
|
|
|
|
|
+ 'properties' => [
|
|
|
|
|
+ 'id' => ['type' => 'integer'],
|
|
|
|
|
+ 'kind' => ['type' => 'string', 'enum' => ['reporter', 'consumer', 'admin']],
|
|
|
|
|
+ 'prefix' => ['type' => 'string', 'example' => 'irdb_adm'],
|
|
|
|
|
+ 'reporter_id' => ['type' => 'integer', 'nullable' => true],
|
|
|
|
|
+ 'consumer_id' => ['type' => 'integer', 'nullable' => true],
|
|
|
|
|
+ 'role' => ['type' => 'string', 'nullable' => true, 'enum' => ['viewer', 'operator', 'admin', null]],
|
|
|
|
|
+ 'expires_at' => ['type' => 'string', 'format' => 'date-time', 'nullable' => true],
|
|
|
|
|
+ 'revoked_at' => ['type' => 'string', 'format' => 'date-time', 'nullable' => true],
|
|
|
|
|
+ 'last_used_at' => ['type' => 'string', 'format' => 'date-time', 'nullable' => true],
|
|
|
|
|
+ ],
|
|
|
|
|
+ ],
|
|
|
|
|
+ 'TokenCreated' => [
|
|
|
|
|
+ 'allOf' => [
|
|
|
|
|
+ ['$ref' => '#/components/schemas/Token'],
|
|
|
|
|
+ [
|
|
|
|
|
+ 'type' => 'object',
|
|
|
|
|
+ 'properties' => [
|
|
|
|
|
+ 'raw_token' => [
|
|
|
|
|
+ 'type' => 'string',
|
|
|
|
|
+ 'description' => 'Returned ONCE on creation — copy it now, never displayed again.',
|
|
|
|
|
+ 'example' => 'irdb_adm_AAAAAAAABBBBBBBBCCCCCCCCDDDDDDDD',
|
|
|
|
|
+ ],
|
|
|
|
|
+ ],
|
|
|
|
|
+ ],
|
|
|
|
|
+ ],
|
|
|
|
|
+ ],
|
|
|
|
|
+ 'Reporter' => [
|
|
|
|
|
+ 'type' => 'object',
|
|
|
|
|
+ 'properties' => [
|
|
|
|
|
+ 'id' => ['type' => 'integer'],
|
|
|
|
|
+ 'name' => ['type' => 'string', 'example' => 'web-prod-01'],
|
|
|
|
|
+ 'description' => ['type' => 'string', 'nullable' => true],
|
|
|
|
|
+ 'trust_weight' => ['type' => 'number', 'format' => 'float', 'minimum' => 0.0, 'maximum' => 2.0],
|
|
|
|
|
+ 'is_active' => ['type' => 'boolean'],
|
|
|
|
|
+ ],
|
|
|
|
|
+ ],
|
|
|
|
|
+ 'Consumer' => [
|
|
|
|
|
+ 'type' => 'object',
|
|
|
|
|
+ 'properties' => [
|
|
|
|
|
+ 'id' => ['type' => 'integer'],
|
|
|
|
|
+ 'name' => ['type' => 'string', 'example' => 'edge-fw-01'],
|
|
|
|
|
+ 'description' => ['type' => 'string', 'nullable' => true],
|
|
|
|
|
+ 'policy_id' => ['type' => 'integer'],
|
|
|
|
|
+ 'is_active' => ['type' => 'boolean'],
|
|
|
|
|
+ 'last_pulled_at' => ['type' => 'string', 'format' => 'date-time', 'nullable' => true],
|
|
|
|
|
+ ],
|
|
|
|
|
+ ],
|
|
|
|
|
+ 'Category' => [
|
|
|
|
|
+ 'type' => 'object',
|
|
|
|
|
+ 'properties' => [
|
|
|
|
|
+ 'id' => ['type' => 'integer'],
|
|
|
|
|
+ 'slug' => ['type' => 'string', 'example' => 'brute_force'],
|
|
|
|
|
+ 'name' => ['type' => 'string'],
|
|
|
|
|
+ 'description' => ['type' => 'string', 'nullable' => true],
|
|
|
|
|
+ 'decay_function' => ['type' => 'string', 'enum' => ['linear', 'exponential']],
|
|
|
|
|
+ 'decay_param' => ['type' => 'number', 'format' => 'float'],
|
|
|
|
|
+ 'is_active' => ['type' => 'boolean'],
|
|
|
|
|
+ ],
|
|
|
|
|
+ ],
|
|
|
|
|
+ 'Policy' => [
|
|
|
|
|
+ 'type' => 'object',
|
|
|
|
|
+ 'properties' => [
|
|
|
|
|
+ 'id' => ['type' => 'integer'],
|
|
|
|
|
+ 'name' => ['type' => 'string', 'example' => 'moderate'],
|
|
|
|
|
+ 'description' => ['type' => 'string', 'nullable' => true],
|
|
|
|
|
+ 'include_manual_blocks' => ['type' => 'boolean'],
|
|
|
|
|
+ 'thresholds' => [
|
|
|
|
|
+ 'type' => 'object',
|
|
|
|
|
+ 'description' => '`{category_slug: threshold}`',
|
|
|
|
|
+ 'additionalProperties' => ['type' => 'number', 'format' => 'float'],
|
|
|
|
|
+ ],
|
|
|
|
|
+ ],
|
|
|
|
|
+ ],
|
|
|
|
|
+ 'ManualBlock' => [
|
|
|
|
|
+ 'type' => 'object',
|
|
|
|
|
+ 'properties' => [
|
|
|
|
|
+ 'id' => ['type' => 'integer'],
|
|
|
|
|
+ 'kind' => ['type' => 'string', 'enum' => ['ip', 'subnet']],
|
|
|
|
|
+ 'ip' => ['type' => 'string', 'nullable' => true],
|
|
|
|
|
+ 'cidr' => ['type' => 'string', 'nullable' => true],
|
|
|
|
|
+ 'reason' => ['type' => 'string', 'nullable' => true],
|
|
|
|
|
+ 'expires_at' => ['type' => 'string', 'format' => 'date-time', 'nullable' => true],
|
|
|
|
|
+ 'created_at' => ['type' => 'string', 'format' => 'date-time'],
|
|
|
|
|
+ ],
|
|
|
|
|
+ ],
|
|
|
|
|
+ 'AllowlistEntry' => [
|
|
|
|
|
+ 'type' => 'object',
|
|
|
|
|
+ 'properties' => [
|
|
|
|
|
+ 'id' => ['type' => 'integer'],
|
|
|
|
|
+ 'kind' => ['type' => 'string', 'enum' => ['ip', 'subnet']],
|
|
|
|
|
+ 'ip' => ['type' => 'string', 'nullable' => true],
|
|
|
|
|
+ 'cidr' => ['type' => 'string', 'nullable' => true],
|
|
|
|
|
+ 'reason' => ['type' => 'string', 'nullable' => true],
|
|
|
|
|
+ 'created_at' => ['type' => 'string', 'format' => 'date-time'],
|
|
|
|
|
+ ],
|
|
|
|
|
+ ],
|
|
|
|
|
+ 'IpDetail' => [
|
|
|
|
|
+ 'type' => 'object',
|
|
|
|
|
+ 'properties' => [
|
|
|
|
|
+ 'ip' => ['type' => 'string'],
|
|
|
|
|
+ 'is_ipv4' => ['type' => 'boolean'],
|
|
|
|
|
+ 'scores' => [
|
|
|
|
|
+ 'type' => 'array',
|
|
|
|
|
+ 'items' => [
|
|
|
|
|
+ 'type' => 'object',
|
|
|
|
|
+ 'properties' => [
|
|
|
|
|
+ 'category' => ['type' => 'string'],
|
|
|
|
|
+ 'score' => ['type' => 'number', 'format' => 'float'],
|
|
|
|
|
+ 'last_report_at' => ['type' => 'string', 'format' => 'date-time', 'nullable' => true],
|
|
|
|
|
+ 'report_count_30d' => ['type' => 'integer'],
|
|
|
|
|
+ ],
|
|
|
|
|
+ ],
|
|
|
|
|
+ ],
|
|
|
|
|
+ 'enrichment' => [
|
|
|
|
|
+ 'type' => 'object',
|
|
|
|
|
+ 'properties' => [
|
|
|
|
|
+ 'country_code' => ['type' => 'string', 'nullable' => true],
|
|
|
|
|
+ 'asn' => ['type' => 'integer', 'nullable' => true],
|
|
|
|
|
+ 'as_org' => ['type' => 'string', 'nullable' => true],
|
|
|
|
|
+ 'enriched_at' => ['type' => 'string', 'format' => 'date-time', 'nullable' => true],
|
|
|
|
|
+ ],
|
|
|
|
|
+ ],
|
|
|
|
|
+ 'status' => ['type' => 'string', 'enum' => ['scored', 'manually_blocked', 'allowlisted', 'clean']],
|
|
|
|
|
+ 'manual_block' => ['type' => 'object', 'nullable' => true],
|
|
|
|
|
+ 'allowlist' => ['type' => 'object', 'nullable' => true],
|
|
|
|
|
+ 'history' => ['type' => 'array', 'items' => ['type' => 'object']],
|
|
|
|
|
+ 'has_more' => ['type' => 'boolean'],
|
|
|
|
|
+ ],
|
|
|
|
|
+ ],
|
|
|
|
|
+ 'AuditEntry' => [
|
|
|
|
|
+ 'type' => 'object',
|
|
|
|
|
+ 'properties' => [
|
|
|
|
|
+ 'id' => ['type' => 'integer'],
|
|
|
|
|
+ 'occurred_at' => ['type' => 'string', 'format' => 'date-time'],
|
|
|
|
|
+ 'actor_kind' => ['type' => 'string', 'enum' => ['user', 'admin-token', 'reporter', 'consumer', 'system']],
|
|
|
|
|
+ 'actor_id' => ['type' => 'string', 'nullable' => true],
|
|
|
|
|
+ 'action' => ['type' => 'string', 'example' => 'manual_block.created'],
|
|
|
|
|
+ 'entity_type' => ['type' => 'string', 'nullable' => true],
|
|
|
|
|
+ 'entity_id' => ['type' => 'string', 'nullable' => true],
|
|
|
|
|
+ 'details' => ['type' => 'object', 'nullable' => true, 'additionalProperties' => true],
|
|
|
|
|
+ 'source_ip' => ['type' => 'string', 'nullable' => true],
|
|
|
|
|
+ ],
|
|
|
|
|
+ ],
|
|
|
|
|
+ 'JobStatus' => [
|
|
|
|
|
+ 'type' => 'object',
|
|
|
|
|
+ 'properties' => [
|
|
|
|
|
+ 'name' => ['type' => 'string'],
|
|
|
|
|
+ 'default_interval_seconds' => ['type' => 'integer'],
|
|
|
|
|
+ 'max_runtime_seconds' => ['type' => 'integer'],
|
|
|
|
|
+ 'overdue' => ['type' => 'boolean'],
|
|
|
|
|
+ 'last_run' => [
|
|
|
|
|
+ 'type' => 'object',
|
|
|
|
|
+ 'nullable' => true,
|
|
|
|
|
+ 'properties' => [
|
|
|
|
|
+ 'id' => ['type' => 'integer'],
|
|
|
|
|
+ 'status' => ['type' => 'string', 'enum' => ['success', 'failure', 'skipped_locked', 'running']],
|
|
|
|
|
+ 'items_processed' => ['type' => 'integer'],
|
|
|
|
|
+ 'triggered_by' => ['type' => 'string', 'enum' => ['schedule', 'manual', 'api']],
|
|
|
|
|
+ 'started_at' => ['type' => 'string', 'format' => 'date-time', 'nullable' => true],
|
|
|
|
|
+ 'finished_at' => ['type' => 'string', 'format' => 'date-time', 'nullable' => true],
|
|
|
|
|
+ 'error_message' => ['type' => 'string', 'nullable' => true],
|
|
|
|
|
+ ],
|
|
|
|
|
+ ],
|
|
|
|
|
+ ],
|
|
|
|
|
+ ],
|
|
|
|
|
+ 'JobOutcome' => [
|
|
|
|
|
+ 'type' => 'object',
|
|
|
|
|
+ 'properties' => [
|
|
|
|
|
+ 'job' => ['type' => 'string'],
|
|
|
|
|
+ 'status' => ['type' => 'string', 'enum' => ['success', 'failure', 'skipped_locked', 'running']],
|
|
|
|
|
+ 'items_processed' => ['type' => 'integer'],
|
|
|
|
|
+ 'duration_ms' => ['type' => 'integer'],
|
|
|
|
|
+ 'run_id' => ['type' => 'integer', 'nullable' => true],
|
|
|
|
|
+ 'error' => ['type' => 'string', 'nullable' => true],
|
|
|
|
|
+ ],
|
|
|
|
|
+ ],
|
|
|
|
|
+ 'User' => [
|
|
|
|
|
+ 'type' => 'object',
|
|
|
|
|
+ 'properties' => [
|
|
|
|
|
+ 'id' => ['type' => 'integer'],
|
|
|
|
|
+ 'email' => ['type' => 'string', 'nullable' => true],
|
|
|
|
|
+ 'display_name' => ['type' => 'string'],
|
|
|
|
|
+ 'role' => ['type' => 'string', 'enum' => ['viewer', 'operator', 'admin']],
|
|
|
|
|
+ 'source' => ['type' => 'string', 'enum' => ['oidc', 'local', 'admin-token']],
|
|
|
|
|
+ 'is_local' => ['type' => 'boolean'],
|
|
|
|
|
+ ],
|
|
|
|
|
+ ],
|
|
|
|
|
+ 'Pagination' => [
|
|
|
|
|
+ 'type' => 'object',
|
|
|
|
|
+ 'properties' => $pageMeta,
|
|
|
|
|
+ ],
|
|
|
|
|
+ ],
|
|
|
|
|
+];
|
|
|
|
|
+
|
|
|
|
|
+$paths = [
|
|
|
|
|
+ // ---------- Public ----------
|
|
|
|
|
+ '/api/v1/report' => [
|
|
|
|
|
+ 'post' => [
|
|
|
|
|
+ 'tags' => ['Public'],
|
|
|
|
|
+ 'summary' => 'Submit an abuse report',
|
|
|
|
|
+ 'description' => "Token kind: `reporter`. Rate limit: 60 req/s per token (configurable).\n"
|
|
|
|
|
+ . 'Returns `202 Accepted` on success.',
|
|
|
|
|
+ 'security' => [['BearerAuth' => []]],
|
|
|
|
|
+ 'requestBody' => [
|
|
|
|
|
+ 'required' => true,
|
|
|
|
|
+ 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/ReportRequest']]],
|
|
|
|
|
+ ],
|
|
|
|
|
+ 'responses' => [
|
|
|
|
|
+ '202' => [
|
|
|
|
|
+ 'description' => 'Report accepted',
|
|
|
|
|
+ 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/ReportResponse']]],
|
|
|
|
|
+ ],
|
|
|
|
|
+ '400' => ['description' => 'Validation failed', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Error']]]],
|
|
|
|
|
+ '401' => ['description' => 'Bad / wrong-kind token', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Error']]]],
|
|
|
|
|
+ '429' => [
|
|
|
|
|
+ 'description' => 'Rate limited',
|
|
|
|
|
+ 'headers' => ['Retry-After' => ['schema' => ['type' => 'integer'], 'description' => 'Seconds to wait before retrying.']],
|
|
|
|
|
+ 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Error']]],
|
|
|
|
|
+ ],
|
|
|
|
|
+ ],
|
|
|
|
|
+ ],
|
|
|
|
|
+ ],
|
|
|
|
|
+ '/api/v1/blocklist' => [
|
|
|
|
|
+ 'get' => [
|
|
|
|
|
+ 'tags' => ['Public'],
|
|
|
|
|
+ 'summary' => 'Pull a tailored blocklist',
|
|
|
|
|
+ 'description' => "Token kind: `consumer`. The consumer's bound policy decides which IPs/CIDRs land in the output.\n"
|
|
|
|
|
+ . "Cached internally for 30 s per consumer. Honour `If-None-Match` to skip retransfer.\n"
|
|
|
|
|
+ . "`?format=json` returns structured rows; default is `text/plain`, one entry per line.",
|
|
|
|
|
+ 'security' => [['BearerAuth' => []]],
|
|
|
|
|
+ 'parameters' => [
|
|
|
|
|
+ ['name' => 'format', 'in' => 'query', 'schema' => ['type' => 'string', 'enum' => ['text', 'json'], 'default' => 'text']],
|
|
|
|
|
+ ['name' => 'If-None-Match', 'in' => 'header', 'schema' => ['type' => 'string']],
|
|
|
|
|
+ ],
|
|
|
|
|
+ 'responses' => [
|
|
|
|
|
+ '200' => [
|
|
|
|
|
+ 'description' => 'Current blocklist',
|
|
|
|
|
+ 'headers' => [
|
|
|
|
|
+ 'ETag' => ['schema' => ['type' => 'string']],
|
|
|
|
|
+ 'X-Blocklist-Generated-At' => ['schema' => ['type' => 'string', 'format' => 'date-time']],
|
|
|
|
|
+ 'X-Blocklist-Entries' => ['schema' => ['type' => 'integer']],
|
|
|
|
|
+ 'X-Blocklist-Policy' => ['schema' => ['type' => 'string']],
|
|
|
|
|
+ ],
|
|
|
|
|
+ 'content' => [
|
|
|
|
|
+ 'text/plain' => ['schema' => ['type' => 'string', 'example' => "203.0.113.42\n198.51.100.0/24\n"]],
|
|
|
|
|
+ 'application/json' => ['schema' => ['$ref' => '#/components/schemas/BlocklistJson']],
|
|
|
|
|
+ ],
|
|
|
|
|
+ ],
|
|
|
|
|
+ '304' => ['description' => 'Not modified — body matches `If-None-Match`'],
|
|
|
|
|
+ '401' => ['description' => 'Bad / wrong-kind token'],
|
|
|
|
|
+ ],
|
|
|
|
|
+ ],
|
|
|
|
|
+ ],
|
|
|
|
|
+
|
|
|
|
|
+ // ---------- Admin: identity ----------
|
|
|
|
|
+ '/api/v1/admin/me' => [
|
|
|
|
|
+ 'get' => [
|
|
|
|
|
+ 'tags' => ['Admin'],
|
|
|
|
|
+ 'summary' => 'Current acting identity',
|
|
|
|
|
+ 'security' => [['BearerAuth' => []]],
|
|
|
|
|
+ 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']],
|
|
|
|
|
+ 'responses' => [
|
|
|
|
|
+ '200' => ['description' => 'Identity info', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/User']]]],
|
|
|
|
|
+ ],
|
|
|
|
|
+ ],
|
|
|
|
|
+ ],
|
|
|
|
|
+
|
|
|
|
|
+ // ---------- Admin: IPs ----------
|
|
|
|
|
+ '/api/v1/admin/ips' => [
|
|
|
|
|
+ 'get' => [
|
|
|
|
|
+ 'tags' => ['Admin'],
|
|
|
|
|
+ 'summary' => 'Search IPs',
|
|
|
|
|
+ 'security' => [['BearerAuth' => []]],
|
|
|
|
|
+ 'parameters' => [
|
|
|
|
|
+ ['$ref' => '#/components/parameters/ActingUserId'],
|
|
|
|
|
+ ['name' => 'q', 'in' => 'query', 'schema' => ['type' => 'string']],
|
|
|
|
|
+ ['name' => 'category', 'in' => 'query', 'schema' => ['type' => 'string']],
|
|
|
|
|
+ ['name' => 'min_score', 'in' => 'query', 'schema' => ['type' => 'number', 'format' => 'float']],
|
|
|
|
|
+ ['name' => 'max_score', 'in' => 'query', 'schema' => ['type' => 'number', 'format' => 'float']],
|
|
|
|
|
+ ['name' => 'country', 'in' => 'query', 'schema' => ['type' => 'string']],
|
|
|
|
|
+ ['name' => 'asn', 'in' => 'query', 'schema' => ['type' => 'integer']],
|
|
|
|
|
+ ['name' => 'status', 'in' => 'query', 'schema' => ['type' => 'string', 'enum' => ['scored', 'manual', 'allowlisted', 'clean']]],
|
|
|
|
|
+ ['$ref' => '#/components/parameters/Page'],
|
|
|
|
|
+ ['$ref' => '#/components/parameters/PageSize'],
|
|
|
|
|
+ ],
|
|
|
|
|
+ 'responses' => ['200' => ['description' => 'Page of IPs', 'content' => ['application/json' => ['schema' => ['type' => 'object', 'properties' => array_merge($pageMeta, ['items' => ['type' => 'array', 'items' => ['type' => 'object']]])]]]]],
|
|
|
|
|
+ ],
|
|
|
|
|
+ ],
|
|
|
|
|
+ '/api/v1/admin/ips/countries' => [
|
|
|
|
|
+ 'get' => [
|
|
|
|
|
+ 'tags' => ['Admin'],
|
|
|
|
|
+ 'summary' => 'Country code dropdown source',
|
|
|
|
|
+ 'security' => [['BearerAuth' => []]],
|
|
|
|
|
+ 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']],
|
|
|
|
|
+ 'responses' => ['200' => ['description' => '`[{code, count}]`', 'content' => ['application/json' => ['schema' => ['type' => 'object', 'properties' => ['items' => ['type' => 'array', 'items' => ['type' => 'object', 'properties' => ['code' => ['type' => 'string'], 'count' => ['type' => 'integer']]]]]]]]]],
|
|
|
|
|
+ ],
|
|
|
|
|
+ ],
|
|
|
|
|
+ '/api/v1/admin/ips/{ip}' => [
|
|
|
|
|
+ 'get' => [
|
|
|
|
|
+ 'tags' => ['Admin'],
|
|
|
|
|
+ 'summary' => 'IP detail',
|
|
|
|
|
+ 'security' => [['BearerAuth' => []]],
|
|
|
|
|
+ 'parameters' => [
|
|
|
|
|
+ ['$ref' => '#/components/parameters/ActingUserId'],
|
|
|
|
|
+ ['name' => 'ip', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'string']],
|
|
|
|
|
+ ],
|
|
|
|
|
+ 'responses' => [
|
|
|
|
|
+ '200' => ['description' => 'IP detail', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/IpDetail']]]],
|
|
|
|
|
+ '404' => ['description' => 'Invalid IP / not found'],
|
|
|
|
|
+ ],
|
|
|
|
|
+ ],
|
|
|
|
|
+ ],
|
|
|
|
|
+
|
|
|
|
|
+ // ---------- Admin: stats / dashboard ----------
|
|
|
|
|
+ '/api/v1/admin/stats/dashboard' => [
|
|
|
|
|
+ 'get' => [
|
|
|
|
|
+ 'tags' => ['Admin'],
|
|
|
|
|
+ 'summary' => 'Dashboard stats (30s cached)',
|
|
|
|
|
+ 'security' => [['BearerAuth' => []]],
|
|
|
|
|
+ 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']],
|
|
|
|
|
+ 'responses' => ['200' => ['description' => 'Dashboard payload', 'content' => ['application/json' => ['schema' => ['type' => 'object']]]]],
|
|
|
|
|
+ ],
|
|
|
|
|
+ ],
|
|
|
|
|
+
|
|
|
|
|
+ // ---------- Admin: manual blocks / allowlist ----------
|
|
|
|
|
+ '/api/v1/admin/manual-blocks' => [
|
|
|
|
|
+ 'get' => ['tags' => ['Admin'], 'summary' => 'List manual blocks', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']], 'responses' => ['200' => ['description' => 'list']]],
|
|
|
|
|
+ 'post' => [
|
|
|
|
|
+ 'tags' => ['Admin'], 'summary' => 'Create manual block',
|
|
|
|
|
+ 'description' => 'Operator+ role required. `kind=ip` requires `ip`; `kind=subnet` requires `cidr`.',
|
|
|
|
|
+ 'security' => [['BearerAuth' => []]],
|
|
|
|
|
+ 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']],
|
|
|
|
|
+ 'requestBody' => ['required' => true, 'content' => ['application/json' => ['schema' => ['type' => 'object', 'properties' => ['kind' => ['type' => 'string', 'enum' => ['ip', 'subnet']], 'ip' => ['type' => 'string'], 'cidr' => ['type' => 'string'], 'reason' => ['type' => 'string'], 'expires_at' => ['type' => 'string', 'format' => 'date-time']]]]]],
|
|
|
|
|
+ 'responses' => ['201' => ['description' => 'Created', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/ManualBlock']]]]],
|
|
|
|
|
+ ],
|
|
|
|
|
+ ],
|
|
|
|
|
+ '/api/v1/admin/manual-blocks/{id}' => [
|
|
|
|
|
+ 'get' => ['tags' => ['Admin'], 'summary' => 'Show manual block', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId'], ['name' => 'id', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'integer']]], 'responses' => ['200' => ['description' => 'manual block', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/ManualBlock']]]]]],
|
|
|
|
|
+ 'delete' => ['tags' => ['Admin'], 'summary' => 'Delete manual block', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId'], ['name' => 'id', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'integer']]], 'responses' => ['204' => ['description' => 'Deleted']]],
|
|
|
|
|
+ ],
|
|
|
|
|
+ '/api/v1/admin/allowlist' => [
|
|
|
|
|
+ 'get' => ['tags' => ['Admin'], 'summary' => 'List allowlist', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']], 'responses' => ['200' => ['description' => 'list']]],
|
|
|
|
|
+ 'post' => ['tags' => ['Admin'], 'summary' => 'Create allowlist entry', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']], 'requestBody' => ['required' => true, 'content' => ['application/json' => ['schema' => ['type' => 'object', 'properties' => ['kind' => ['type' => 'string', 'enum' => ['ip', 'subnet']], 'ip' => ['type' => 'string'], 'cidr' => ['type' => 'string'], 'reason' => ['type' => 'string']]]]]], 'responses' => ['201' => ['description' => 'Created', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/AllowlistEntry']]]]]],
|
|
|
|
|
+ ],
|
|
|
|
|
+ '/api/v1/admin/allowlist/{id}' => [
|
|
|
|
|
+ 'get' => ['tags' => ['Admin'], 'summary' => 'Show allowlist entry', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId'], ['name' => 'id', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'integer']]], 'responses' => ['200' => ['description' => 'allowlist entry', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/AllowlistEntry']]]]]],
|
|
|
|
|
+ 'delete' => ['tags' => ['Admin'], 'summary' => 'Delete allowlist entry', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId'], ['name' => 'id', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'integer']]], 'responses' => ['204' => ['description' => 'Deleted']]],
|
|
|
|
|
+ ],
|
|
|
|
|
+
|
|
|
|
|
+ // ---------- Admin: reporters / consumers / tokens ----------
|
|
|
|
|
+ '/api/v1/admin/reporters' => [
|
|
|
|
|
+ 'get' => ['tags' => ['Admin'], 'summary' => 'List reporters', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']], 'responses' => ['200' => ['description' => 'list']]],
|
|
|
|
|
+ 'post' => ['tags' => ['Admin'], 'summary' => 'Create reporter', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']], 'requestBody' => ['required' => true, 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Reporter']]]], 'responses' => ['201' => ['description' => 'Created', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Reporter']]]]]],
|
|
|
|
|
+ ],
|
|
|
|
|
+ '/api/v1/admin/reporters/{id}' => [
|
|
|
|
|
+ 'get' => ['tags' => ['Admin'], 'summary' => 'Show reporter', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId'], ['name' => 'id', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'integer']]], 'responses' => ['200' => ['description' => 'reporter', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Reporter']]]]]],
|
|
|
|
|
+ 'patch' => ['tags' => ['Admin'], 'summary' => 'Update reporter', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId'], ['name' => 'id', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'integer']]], 'requestBody' => ['content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Reporter']]]], 'responses' => ['200' => ['description' => 'Updated', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Reporter']]]]]],
|
|
|
|
|
+ 'delete' => ['tags' => ['Admin'], 'summary' => 'Soft-delete reporter', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId'], ['name' => 'id', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'integer']]], 'responses' => ['204' => ['description' => 'Deleted'], '409' => ['description' => 'Has reports — flagged inactive instead.']]],
|
|
|
|
|
+ ],
|
|
|
|
|
+ '/api/v1/admin/consumers' => [
|
|
|
|
|
+ 'get' => ['tags' => ['Admin'], 'summary' => 'List consumers', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']], 'responses' => ['200' => ['description' => 'list']]],
|
|
|
|
|
+ 'post' => ['tags' => ['Admin'], 'summary' => 'Create consumer', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']], 'requestBody' => ['required' => true, 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Consumer']]]], 'responses' => ['201' => ['description' => 'Created', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Consumer']]]]]],
|
|
|
|
|
+ ],
|
|
|
|
|
+ '/api/v1/admin/consumers/{id}' => [
|
|
|
|
|
+ 'get' => ['tags' => ['Admin'], 'summary' => 'Show consumer', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId'], ['name' => 'id', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'integer']]], 'responses' => ['200' => ['description' => 'consumer', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Consumer']]]]]],
|
|
|
|
|
+ 'patch' => ['tags' => ['Admin'], 'summary' => 'Update consumer', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId'], ['name' => 'id', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'integer']]], 'requestBody' => ['content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Consumer']]]], 'responses' => ['200' => ['description' => 'Updated']]],
|
|
|
|
|
+ 'delete' => ['tags' => ['Admin'], 'summary' => 'Soft-delete consumer', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId'], ['name' => 'id', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'integer']]], 'responses' => ['204' => ['description' => 'Deleted']]],
|
|
|
|
|
+ ],
|
|
|
|
|
+ '/api/v1/admin/tokens' => [
|
|
|
|
|
+ 'get' => ['tags' => ['Admin'], 'summary' => 'List tokens', 'description' => 'Service tokens are filtered out unconditionally.', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']], 'responses' => ['200' => ['description' => 'list']]],
|
|
|
|
|
+ 'post' => ['tags' => ['Admin'], 'summary' => 'Create token', 'description' => 'Returns the raw token ONCE in `raw_token`. Service tokens are not creatable here.', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']], 'requestBody' => ['required' => true, 'content' => ['application/json' => ['schema' => ['type' => 'object', 'properties' => ['kind' => ['type' => 'string', 'enum' => ['reporter', 'consumer', 'admin']], 'reporter_id' => ['type' => 'integer'], 'consumer_id' => ['type' => 'integer'], 'role' => ['type' => 'string', 'enum' => ['viewer', 'operator', 'admin']], 'expires_at' => ['type' => 'string', 'format' => 'date-time']]]]]], 'responses' => ['201' => ['description' => 'Created', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/TokenCreated']]]]]],
|
|
|
|
|
+ ],
|
|
|
|
|
+ '/api/v1/admin/tokens/{id}' => [
|
|
|
|
|
+ 'delete' => ['tags' => ['Admin'], 'summary' => 'Revoke token', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId'], ['name' => 'id', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'integer']]], 'responses' => ['204' => ['description' => 'Revoked']]],
|
|
|
|
|
+ ],
|
|
|
|
|
+
|
|
|
|
|
+ // ---------- Admin: categories / policies ----------
|
|
|
|
|
+ '/api/v1/admin/categories' => [
|
|
|
|
|
+ 'get' => ['tags' => ['Admin'], 'summary' => 'List categories', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']], 'responses' => ['200' => ['description' => 'list']]],
|
|
|
|
|
+ 'post' => ['tags' => ['Admin'], 'summary' => 'Create category', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']], 'requestBody' => ['required' => true, 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Category']]]], 'responses' => ['201' => ['description' => 'Created', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Category']]]]]],
|
|
|
|
|
+ ],
|
|
|
|
|
+ '/api/v1/admin/categories/{id}' => [
|
|
|
|
|
+ 'get' => ['tags' => ['Admin'], 'summary' => 'Show category', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId'], ['name' => 'id', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'integer']]], 'responses' => ['200' => ['description' => 'category', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Category']]]]]],
|
|
|
|
|
+ 'patch' => ['tags' => ['Admin'], 'summary' => 'Update category', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId'], ['name' => 'id', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'integer']]], 'requestBody' => ['content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Category']]]], 'responses' => ['200' => ['description' => 'Updated']]],
|
|
|
|
|
+ 'delete' => ['tags' => ['Admin'], 'summary' => 'Hard-delete category (refused if in use)', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId'], ['name' => 'id', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'integer']]], 'responses' => ['204' => ['description' => 'Deleted'], '409' => ['description' => 'Category in use; soft-delete via PATCH `is_active=false`.']]],
|
|
|
|
|
+ ],
|
|
|
|
|
+ '/api/v1/admin/policies' => [
|
|
|
|
|
+ 'get' => ['tags' => ['Admin'], 'summary' => 'List policies', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']], 'responses' => ['200' => ['description' => 'list']]],
|
|
|
|
|
+ 'post' => ['tags' => ['Admin'], 'summary' => 'Create policy', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']], 'requestBody' => ['required' => true, 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Policy']]]], 'responses' => ['201' => ['description' => 'Created', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Policy']]]]]],
|
|
|
|
|
+ ],
|
|
|
|
|
+ '/api/v1/admin/policies/{id}' => [
|
|
|
|
|
+ 'get' => ['tags' => ['Admin'], 'summary' => 'Show policy', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId'], ['name' => 'id', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'integer']]], 'responses' => ['200' => ['description' => 'policy', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Policy']]]]]],
|
|
|
|
|
+ 'patch' => ['tags' => ['Admin'], 'summary' => 'Update policy (replaces thresholds wholesale when present)', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId'], ['name' => 'id', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'integer']]], 'requestBody' => ['content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Policy']]]], 'responses' => ['200' => ['description' => 'Updated']]],
|
|
|
|
|
+ 'delete' => ['tags' => ['Admin'], 'summary' => 'Delete policy (refused if used by consumers)', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId'], ['name' => 'id', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'integer']]], 'responses' => ['204' => ['description' => 'Deleted'], '409' => ['description' => 'Policy in use']]],
|
|
|
|
|
+ ],
|
|
|
|
|
+ '/api/v1/admin/policies/{id}/preview' => [
|
|
|
|
|
+ 'get' => ['tags' => ['Admin'], 'summary' => 'Preview policy (count + sample)', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId'], ['name' => 'id', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'integer']]], 'responses' => ['200' => ['description' => 'Preview']]],
|
|
|
|
|
+ ],
|
|
|
|
|
+
|
|
|
|
|
+ // ---------- Admin: audit / jobs / config ----------
|
|
|
|
|
+ '/api/v1/admin/audit-log' => [
|
|
|
|
|
+ 'get' => [
|
|
|
|
|
+ 'tags' => ['Admin'], 'summary' => 'Filtered audit log',
|
|
|
|
|
+ 'security' => [['BearerAuth' => []]],
|
|
|
|
|
+ 'parameters' => [
|
|
|
|
|
+ ['$ref' => '#/components/parameters/ActingUserId'],
|
|
|
|
|
+ ['name' => 'actor_kind', 'in' => 'query', 'schema' => ['type' => 'string', 'enum' => ['user', 'admin-token', 'reporter', 'consumer', 'system']]],
|
|
|
|
|
+ ['name' => 'actor_id', 'in' => 'query', 'schema' => ['type' => 'integer']],
|
|
|
|
|
+ ['name' => 'action', 'in' => 'query', 'schema' => ['type' => 'string']],
|
|
|
|
|
+ ['name' => 'entity_type', 'in' => 'query', 'schema' => ['type' => 'string']],
|
|
|
|
|
+ ['name' => 'entity_id', 'in' => 'query', 'schema' => ['type' => 'string']],
|
|
|
|
|
+ ['name' => 'from', 'in' => 'query', 'schema' => ['type' => 'string', 'format' => 'date-time']],
|
|
|
|
|
+ ['name' => 'to', 'in' => 'query', 'schema' => ['type' => 'string', 'format' => 'date-time']],
|
|
|
|
|
+ ['$ref' => '#/components/parameters/Page'],
|
|
|
|
|
+ ['$ref' => '#/components/parameters/PageSize'],
|
|
|
|
|
+ ],
|
|
|
|
|
+ 'responses' => ['200' => ['description' => 'Audit page', 'content' => ['application/json' => ['schema' => ['type' => 'object', 'properties' => array_merge($pageMeta, ['items' => ['type' => 'array', 'items' => ['$ref' => '#/components/schemas/AuditEntry']]])]]]]],
|
|
|
|
|
+ ],
|
|
|
|
|
+ ],
|
|
|
|
|
+ '/api/v1/admin/jobs/status' => [
|
|
|
|
|
+ 'get' => ['tags' => ['Admin'], 'summary' => 'Jobs status (Viewer)', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']], 'responses' => ['200' => ['description' => 'Jobs status', 'content' => ['application/json' => ['schema' => ['type' => 'object', 'properties' => ['now' => ['type' => 'string', 'format' => 'date-time'], 'jobs' => ['type' => 'object', 'additionalProperties' => ['$ref' => '#/components/schemas/JobStatus']]]]]]]]],
|
|
|
|
|
+ ],
|
|
|
|
|
+ '/api/v1/admin/jobs/trigger/{name}' => [
|
|
|
|
|
+ 'post' => [
|
|
|
|
|
+ 'tags' => ['Admin'], 'summary' => 'Manually trigger a job (Admin)',
|
|
|
|
|
+ 'description' => 'Whitelisted params: `full`, `max_rows`, `reenrich`. Other body fields are dropped.',
|
|
|
|
|
+ 'security' => [['BearerAuth' => []]],
|
|
|
|
|
+ 'parameters' => [['$ref' => '#/components/parameters/ActingUserId'], ['name' => 'name', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'string']]],
|
|
|
|
|
+ 'requestBody' => ['content' => ['application/json' => ['schema' => ['type' => 'object', 'properties' => ['full' => ['type' => 'boolean'], 'max_rows' => ['type' => 'integer'], 'reenrich' => ['type' => 'boolean']]]]]],
|
|
|
|
|
+ 'responses' => [
|
|
|
|
|
+ '200' => ['description' => 'Job ran', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/JobOutcome']]]],
|
|
|
|
|
+ '404' => ['description' => 'Unknown job'],
|
|
|
|
|
+ '409' => ['description' => 'Lock held; status=`skipped_locked`'],
|
|
|
|
|
+ '412' => ['description' => 'refresh-geoip without credential'],
|
|
|
|
|
+ ],
|
|
|
|
|
+ ],
|
|
|
|
|
+ ],
|
|
|
|
|
+ '/api/v1/admin/config' => [
|
|
|
|
|
+ 'get' => ['tags' => ['Admin'], 'summary' => 'Effective config (secrets masked)', 'description' => 'Admin only.', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']], 'responses' => ['200' => ['description' => 'Config sections', 'content' => ['application/json' => ['schema' => ['type' => 'object', 'properties' => ['sections' => ['type' => 'object', 'additionalProperties' => ['type' => 'object']]]]]]]]],
|
|
|
|
|
+ ],
|
|
|
|
|
+
|
|
|
|
|
+ // ---------- Auth (UI BFF only) ----------
|
|
|
|
|
+ '/api/v1/auth/users/upsert-oidc' => [
|
|
|
|
|
+ 'post' => [
|
|
|
|
|
+ 'tags' => ['Auth'],
|
|
|
|
|
+ 'summary' => 'Upsert an OIDC-authenticated user',
|
|
|
|
|
+ 'description' => "**UI BFF only.** Service-token-required, no impersonation header.\n"
|
|
|
|
|
+ . 'Resolves the user record + role (via `oidc_role_mappings`) for a freshly-validated ID token.',
|
|
|
|
|
+ 'x-internal' => true,
|
|
|
|
|
+ 'security' => [['BearerAuth' => []]],
|
|
|
|
|
+ 'requestBody' => ['required' => true, 'content' => ['application/json' => ['schema' => ['type' => 'object', 'properties' => ['subject' => ['type' => 'string'], 'email' => ['type' => 'string'], 'display_name' => ['type' => 'string'], 'groups' => ['type' => 'array', 'items' => ['type' => 'string']]]]]]],
|
|
|
|
|
+ 'responses' => ['200' => ['description' => 'User record', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/User']]]]],
|
|
|
|
|
+ ],
|
|
|
|
|
+ ],
|
|
|
|
|
+ '/api/v1/auth/users/upsert-local' => [
|
|
|
|
|
+ 'post' => [
|
|
|
|
|
+ 'tags' => ['Auth'],
|
|
|
|
|
+ 'summary' => 'Upsert the local-admin user',
|
|
|
|
|
+ 'description' => '**UI BFF only.** Called after the UI validates the local-admin password.',
|
|
|
|
|
+ 'x-internal' => true,
|
|
|
|
|
+ 'security' => [['BearerAuth' => []]],
|
|
|
|
|
+ 'requestBody' => ['required' => true, 'content' => ['application/json' => ['schema' => ['type' => 'object', 'properties' => ['username' => ['type' => 'string']]]]]],
|
|
|
|
|
+ 'responses' => ['200' => ['description' => 'User record', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/User']]]]],
|
|
|
|
|
+ ],
|
|
|
|
|
+ ],
|
|
|
|
|
+ '/api/v1/auth/users/{id}' => [
|
|
|
|
|
+ 'get' => [
|
|
|
|
|
+ 'tags' => ['Auth'],
|
|
|
|
|
+ 'summary' => 'Refetch a user record',
|
|
|
|
|
+ 'description' => '**UI BFF only.** Used to refresh role / display_name during a session.',
|
|
|
|
|
+ 'x-internal' => true,
|
|
|
|
|
+ 'security' => [['BearerAuth' => []]],
|
|
|
|
|
+ 'parameters' => [['name' => 'id', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'integer']]],
|
|
|
|
|
+ 'responses' => ['200' => ['description' => 'User record', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/User']]]]],
|
|
|
|
|
+ ],
|
|
|
|
|
+ ],
|
|
|
|
|
+];
|
|
|
|
|
+
|
|
|
|
|
+$spec = [
|
|
|
|
|
+ 'openapi' => '3.0.3',
|
|
|
|
|
+ 'info' => [
|
|
|
|
|
+ 'title' => 'IRDB — IP Reputation Database',
|
|
|
|
|
+ 'version' => '1.0.0',
|
|
|
|
|
+ 'description' => "Self-hosted IP reputation service: ingest abuse reports, distribute tailored block lists.\n\n"
|
|
|
|
|
+ . "## Versioning\n\n"
|
|
|
|
|
+ . "Single major version `v1`. Changes within `v1` are additive only — new endpoints, new optional fields, new optional query params. Breaking changes ship as `v2`.\n\n"
|
|
|
|
|
+ . "## Endpoint groups\n\n"
|
|
|
|
|
+ . "- **Public**: machine clients (reporters, consumers).\n"
|
|
|
|
|
+ . "- **Admin**: UI BFF + admin-kind tokens. RBAC enforced server-side.\n"
|
|
|
|
|
+ . "- **Auth**: UI BFF only — bridges browser auth to user records. Marked `x-internal: true`.\n"
|
|
|
|
|
+ . "- **Internal jobs**: not in this spec. Scheduler-only, network-restricted.\n\n"
|
|
|
|
|
+ . "## Authentication\n\n"
|
|
|
|
|
+ . "Bearer token in the `Authorization` header. Four token kinds: `reporter`, `consumer`, `admin`, `service`. See `doc/auth-flows.md`.\n\n"
|
|
|
|
|
+ . "## Errors\n\n"
|
|
|
|
|
+ . "Uniform envelope: `{\"error\":\"<code>\",\"details\":{...}}`. Validation errors include `details`. Authentication failures return `401` `unauthorized`. Authorization failures return `403`.\n\n"
|
|
|
|
|
+ . "## Rate limiting\n\n"
|
|
|
|
|
+ . "Public endpoints: 60 req/s/token (configurable). On exhaustion: `429` with `Retry-After: 1`.",
|
|
|
|
|
+ 'license' => ['name' => 'TBD'],
|
|
|
|
|
+ ],
|
|
|
|
|
+ 'servers' => [
|
|
|
|
|
+ ['url' => 'http://localhost:8081', 'description' => 'Default compose deployment'],
|
|
|
|
|
+ ['url' => 'https://reputation-api.example.com', 'description' => 'Production (replace hostname)'],
|
|
|
|
|
+ ],
|
|
|
|
|
+ 'tags' => [
|
|
|
|
|
+ ['name' => 'Public', 'description' => 'Machine clients: reporters and blocklist consumers.'],
|
|
|
|
|
+ ['name' => 'Admin', 'description' => 'UI BFF + admin-kind tokens.'],
|
|
|
|
|
+ ['name' => 'Auth', 'description' => 'UI BFF only. Service token required, no impersonation.'],
|
|
|
|
|
+ ],
|
|
|
|
|
+ 'security' => [['BearerAuth' => []]],
|
|
|
|
|
+ 'paths' => $paths,
|
|
|
|
|
+ 'components' => $components,
|
|
|
|
|
+];
|
|
|
|
|
+
|
|
|
|
|
+echo dumpYaml($spec);
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * Minimal YAML dumper that handles the subset we use here:
|
|
|
|
|
+ * - assoc arrays become mappings
|
|
|
|
|
+ * - lists become block sequences
|
|
|
|
|
+ * - strings get quoted only when needed (newlines, leading specials, special chars)
|
|
|
|
|
+ * - bools / ints / floats / null serialise plainly
|
|
|
|
|
+ *
|
|
|
|
|
+ * @param mixed $value
|
|
|
|
|
+ */
|
|
|
|
|
+function dumpYaml(mixed $value, int $indent = 0): string
|
|
|
|
|
+{
|
|
|
|
|
+ if (!is_array($value)) {
|
|
|
|
|
+ return scalarYaml($value) . "\n";
|
|
|
|
|
+ }
|
|
|
|
|
+ if ($value === []) {
|
|
|
|
|
+ // Caller decides whether `[]` is "empty list" or "empty object".
|
|
|
|
|
+ // We always emit empty list — callers that need an empty object
|
|
|
|
|
+ // pass a sentinel (we don't have any in this spec).
|
|
|
|
|
+ return "[]\n";
|
|
|
|
|
+ }
|
|
|
|
|
+ $isList = array_is_list($value);
|
|
|
|
|
+ $out = '';
|
|
|
|
|
+ $pad = str_repeat(' ', $indent);
|
|
|
|
|
+ foreach ($value as $k => $v) {
|
|
|
|
|
+ if ($isList) {
|
|
|
|
|
+ if (is_array($v) && $v !== []) {
|
|
|
|
|
+ // Render the child at indent 0, then prefix the first line
|
|
|
|
|
+ // with "- " and subsequent lines with two-space continuation.
|
|
|
|
|
+ $rawSub = rtrim(dumpYaml($v, 0), "\n");
|
|
|
|
|
+ $childPad = str_repeat(' ', $indent + 1);
|
|
|
|
|
+ $lines = explode("\n", $rawSub);
|
|
|
|
|
+ $first = true;
|
|
|
|
|
+ foreach ($lines as $line) {
|
|
|
|
|
+ if ($first) {
|
|
|
|
|
+ $out .= $pad . '- ' . $line . "\n";
|
|
|
|
|
+ $first = false;
|
|
|
|
|
+ } else {
|
|
|
|
|
+ $out .= $childPad . $line . "\n";
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ } else {
|
|
|
|
|
+ $out .= $pad . '- ' . scalarYaml($v, $indent) . "\n";
|
|
|
|
|
+ }
|
|
|
|
|
+ } else {
|
|
|
|
|
+ $key = (string) $k;
|
|
|
|
|
+ // Quote keys that contain special chars
|
|
|
|
|
+ if (preg_match('/^[A-Za-z_][A-Za-z0-9_-]*$/', $key) !== 1 || $key === 'on' || $key === 'no' || $key === 'yes' || $key === 'off') {
|
|
|
|
|
+ $key = "'" . str_replace("'", "''", $key) . "'";
|
|
|
|
|
+ }
|
|
|
|
|
+ if (is_array($v)) {
|
|
|
|
|
+ if ($v === []) {
|
|
|
|
|
+ $out .= $pad . $key . ": []\n";
|
|
|
|
|
+ } else {
|
|
|
|
|
+ $out .= $pad . $key . ":\n" . dumpYaml($v, $indent + 1);
|
|
|
|
|
+ }
|
|
|
|
|
+ } else {
|
|
|
|
|
+ $out .= $pad . $key . ': ' . scalarYaml($v, $indent) . "\n";
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return $out;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function scalarYaml(mixed $value, int $parentIndent = 0): string
|
|
|
|
|
+{
|
|
|
|
|
+ if ($value === null) {
|
|
|
|
|
+ return 'null';
|
|
|
|
|
+ }
|
|
|
|
|
+ if (is_bool($value)) {
|
|
|
|
|
+ return $value ? 'true' : 'false';
|
|
|
|
|
+ }
|
|
|
|
|
+ if (is_int($value) || is_float($value)) {
|
|
|
|
|
+ return (string) $value;
|
|
|
|
|
+ }
|
|
|
|
|
+ $s = (string) $value;
|
|
|
|
|
+ if ($s === '') {
|
|
|
|
|
+ return "''";
|
|
|
|
|
+ }
|
|
|
|
|
+ // Multi-line: literal block scalar. Content must be indented one level
|
|
|
|
|
+ // *deeper* than the key — i.e. `parentIndent + 1`. The caller already
|
|
|
|
|
+ // emitted "<key>: <this>" so we return "|\n<indented lines>".
|
|
|
|
|
+ if (str_contains($s, "\n")) {
|
|
|
|
|
+ $lines = explode("\n", rtrim($s, "\n"));
|
|
|
|
|
+ $contentPad = str_repeat(' ', $parentIndent + 1);
|
|
|
|
|
+ $block = '|';
|
|
|
|
|
+ foreach ($lines as $line) {
|
|
|
|
|
+ $block .= "\n" . ($line === '' ? '' : $contentPad . $line);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return $block;
|
|
|
|
|
+ }
|
|
|
|
|
+ // Quote if it could be misread as bool/null/number, contains special chars, or starts with sigils.
|
|
|
|
|
+ $needsQuote = preg_match('/^(true|false|null|yes|no|on|off|~)$/i', $s) === 1
|
|
|
|
|
+ || preg_match('/^[\\-?:,\\[\\]\\{\\}#&*!|>\'"%@`]/', $s) === 1
|
|
|
|
|
+ || preg_match('/^[+\\-]?\\d/', $s) === 1
|
|
|
|
|
+ || str_contains($s, ': ')
|
|
|
|
|
+ || str_contains($s, ' #')
|
|
|
|
|
+ || str_starts_with($s, ' ')
|
|
|
|
|
+ || str_ends_with($s, ' ');
|
|
|
|
|
+ if ($needsQuote) {
|
|
|
|
|
+ return "'" . str_replace("'", "''", $s) . "'";
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return $s;
|
|
|
|
|
+}
|